diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 39d2fcd..14cdfd6 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -356,6 +356,29 @@
+ +
+
萃取引擎
+
同步小幫手把你的文件變成知識卡時用的 AI。選好後請讓小幫手重連一次。
+
+
+ + +
+
+
aistudio.google.com/apikey 免費取得金鑰
+ +
+ +
+
+
@@ -793,6 +816,70 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { }); })(); + // t122 萃取引擎設定(engine 切換顯示隱藏金鑰輸入框 + 存檔) + (function () { + // 引擎切換:Gemini 顯示金鑰欄;Claude Code 隱藏 + function updateExtractorKeyRowVisibility() { + var row = $('st-extractor-key-row'); + if (!row) return; + var el = document.getElementById('st-engine-gemma'); + row.style.display = (el && el.checked) ? '' : 'none'; + } + ['st-engine-gemma', 'st-engine-claude'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.addEventListener('change', updateExtractorKeyRowVisibility); + }); + updateExtractorKeyRowVisibility(); + + // 進入設定頁時讀取現有設定(GET /portal/admin/extractor) + function loadExtractorConfig() { + if (!(S.profile && S.profile.role === 'admin')) return; + fetch(API_BASE + '/portal/admin/extractor', { headers: authHeaders() }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!d) return; + var gemmaEl = document.getElementById('st-engine-gemma'); + var claudeEl = document.getElementById('st-engine-claude'); + if (d.engine === 'claude' && claudeEl) claudeEl.checked = true; + else if (gemmaEl) gemmaEl.checked = true; + updateExtractorKeyRowVisibility(); + if (d.has_key) { + var ki = $('st-extractor-key'); + if (ki) ki.placeholder = '已設定(留空=不變更)'; + } + }) + .catch(function () { /* 讀不到不擋頁面 */ }); + } + + // 存檔 + var sb = $('st-extractor-save'); + if (sb) sb.addEventListener('click', function () { + var m = $('st-extractor-status'); + var engineEl = document.querySelector('input[name="st-extractor-engine"]:checked'); + var engine = engineEl ? engineEl.value : 'gemma'; + var key = (($('st-extractor-key') && $('st-extractor-key').value) || '').trim(); + var body = { engine: engine }; + if (engine === 'gemma' && key) body.gemini_api_key = key; + sb.disabled = true; m.textContent = '儲存中…'; m.style.color = ''; + fetch(API_BASE + '/portal/admin/extractor', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), + body: JSON.stringify(body) + }).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (x) { + sb.disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; } + if ($('st-extractor-key')) { $('st-extractor-key').value = ''; $('st-extractor-key').placeholder = '已設定(留空=不變更)'; } + m.textContent = '已儲存。小幫手請點「連上知識庫」重連一次即可生效'; m.style.color = '#3f7a4f'; + }) + .catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; }); + }); + + // loadExtractorConfig 掛到全域(供 loadSettings 呼叫) + window._loadExtractorConfig = loadExtractorConfig; + })(); + $('st-logout').addEventListener('click', function () { fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ }); dropSession(); @@ -1605,6 +1692,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { function loadSettings() { var p = S.profile; if (!p) { $('st-me').innerHTML = '
載入中…
'; return; } + if (window._loadExtractorConfig) window._loadExtractorConfig(); var libs = p.libraries || []; var libHtml = libs.indexOf('*') >= 0 ? '全部知識庫' diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 84abaa9..207eb0a 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -755,10 +755,32 @@ portalRouter.post('/portal/daemon/libraries', (c) => }), ); +// ── t122 萃取引擎設定(daemon 萃取用;與 chat-key AI 問答金鑰獨立管理)────────────── +// KV key = {tenant}:portal:extractor_config,存在 WEBHOOKS KV(同 chat-key 手法)。 +// 金鑰不落 log;GET 只回 has_key:bool,不回明文。 +// daemon 未設定時預設 gemma(封測者不會有 claude,以 gemma 為友善預設)。 + +interface ExtractorConfig { + engine: 'gemma' | 'claude'; + gemini_api_key?: string; + llm_model?: string; +} + +function extractorConfigKey(env: Bindings): string { + return `${portalTenant(env)}:portal:extractor_config`; +} + +async function getExtractorConfig(env: Bindings): Promise { + const raw = await env.WEBHOOKS.get(extractorConfigKey(env), 'text'); + if (!raw) return null; + try { return JSON.parse(raw) as ExtractorConfig; } catch { return null; } +} + // POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」 // 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)—— // 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。 // 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。 +// t122:extractor 改讀雲端設定(未設→預設 gemma;gemma+金鑰→一併下發金鑰)。 portalRouter.post('/portal/daemon/config', (c) => run(c, async () => { const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string } | null; @@ -781,17 +803,21 @@ portalRouter.post('/portal/daemon/config', (c) => } await clearLoginFail(c.env, email); const tenant = portalTenant(c.env); - return c.json({ - success: true, - config: { - cypher_url: new URL(c.req.url).origin, - namespace: tenant, - library: 'kb', - extractor: 'claude', - email, - instance_name: String(rec.values.display_name ?? ''), - }, - }); + const extractorCfg = await getExtractorConfig(c.env); + const engine = extractorCfg?.engine ?? 'gemma'; + const daemonCfg: Record = { + cypher_url: new URL(c.req.url).origin, + namespace: tenant, + library: 'kb', + extractor: engine, + email, + instance_name: String(rec.values.display_name ?? ''), + }; + if (engine === 'gemma' && extractorCfg?.gemini_api_key) { + daemonCfg.gemini_api_key = extractorCfg.gemini_api_key; + } + if (extractorCfg?.llm_model) daemonCfg.llm_model = extractorCfg.llm_model; + return c.json({ success: true, config: daemonCfg }); }), ); @@ -837,6 +863,44 @@ portalRouter.post('/portal/admin/chat-key', (c) => }), ); +// POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}(t122)。 +// admin 閘(同 chat-key 等級)。金鑰不落 log;存 WEBHOOKS KV。 +portalRouter.post('/portal/admin/extractor', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = (await c.req.json().catch(() => null)) as { engine?: string; gemini_api_key?: string; llm_model?: string } | null; + const engine = String(body?.engine ?? '').trim().toLowerCase(); + if (engine !== 'gemma' && engine !== 'claude') { + return c.json({ error: 'engine 只能是 gemma 或 claude' }, 400); + } + const cfg: ExtractorConfig = { engine: engine as 'gemma' | 'claude' }; + if (engine === 'gemma') { + const key = String(body?.gemini_api_key ?? '').trim(); + if (key) cfg.gemini_api_key = key; + } + const model = String(body?.llm_model ?? '').trim(); + if (model) cfg.llm_model = model; + await c.env.WEBHOOKS.put(extractorConfigKey(c.env), JSON.stringify(cfg)); + return c.json({ success: true, engine: cfg.engine, has_key: engine === 'gemma' && !!cfg.gemini_api_key }); + }), +); + +// GET /portal/admin/extractor — 回 engine + has_key(不回金鑰明文)(t122)。 +portalRouter.get('/portal/admin/extractor', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const cfg = await getExtractorConfig(c.env); + return c.json({ + success: true, + engine: cfg?.engine ?? 'gemma', + has_key: cfg?.engine === 'gemma' && !!cfg?.gemini_api_key, + llm_model: cfg?.llm_model ?? null, + }); + }), +); + // GET /portal/admin/libraries — 庫目錄列表。 // t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」): // 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)—— diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index d6aaa1e..52771f7 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -395,7 +395,71 @@ describe('/portal/admin/libraries', () => { }); }); -// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════ +// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════ + +describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => { + const USER_EMAIL = 'daemon@example.com'; + const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算) + const USER_RECORD = 'rec_daemon_user'; + const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; // wrangler.test.toml CONSOLE_TENANT=leo + + /** mock email head lookup(findUserRecordId 走這個路徑)*/ + function mockEmailLookup(email: string, recordId: string | null) { + const needle = new URLSearchParams({ page_name: email }).toString(); + fetchMock + .get(KBDB) + .intercept({ + path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)), + method: 'GET', + }) + .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 }); + } + + it('未設定 → daemon/config 下發 extractor=gemma,無 gemini_api_key', async () => { + // 確保 KV 沒有 extractor config + await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); + mockEmailLookup(USER_EMAIL, USER_RECORD); + mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash })); + const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; config: Record }; + expect(data.success).toBe(true); + expect(data.config.extractor).toBe('gemma'); + expect('gemini_api_key' in data.config).toBe(false); + }); + + it('設定 gemma+金鑰後 → daemon/config 下發含 gemini_api_key', async () => { + await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-test-key-999' })); + mockEmailLookup(USER_EMAIL, USER_RECORD); + mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash })); + const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; config: Record }; + expect(data.config.extractor).toBe('gemma'); + expect(data.config.gemini_api_key).toBe('AIza-test-key-999'); + // cleanup + await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); + }); + + it('GET /portal/admin/extractor → has_key=true,回應不含金鑰明文', async () => { + await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-secret-key' })); + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + const res = await json('GET', '/portal/admin/extractor', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; engine: string; has_key: boolean }; + expect(data.engine).toBe('gemma'); + expect(data.has_key).toBe(true); + // 回應主體不含金鑰明文 + const raw = JSON.stringify(data); + expect(raw).not.toContain('AIza-secret-key'); + expect(raw).not.toContain('gemini_api_key'); + // cleanup + await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); + }); +}); + +// ═══════════════ 7. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════ describe('GET /portal(P4 admin 頁 HTML 殼)', () => { it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => { diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 37cdb12..66a471e 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -215,6 +215,33 @@ kbdb 與 cypher 兩個 worker;workflow yaml 同批替換 `__KBDB_TOKEN__`(見規格)即封口。 kbdb_upsert_block WASM 指向死路由 `/blocks`(設 token 前後都壞,不新增破壞)。 +- [x] **t116 圖搜尋靜默失敗——portal 補傳 kbdb_base(2026-07-29,任務層小改)**: + 診斷:graph_neighbors workflow 的 fetch_triplets url 含 `{{input.kbdb_base}}/records/...`, + 但 portal 呼叫 executeWebhookGraph 時未傳 kbdb_base → URL 解析失敗 → `HTTP request failed`。 + 修法(b 案 雙保險):portal-data.ts 補傳 `kbdb_base: env.KBDB_BASE_URL ?? ''`; + yaml 端(`__KBDB_BASE__` 安裝期替換)由總管派 arcrun-rag,詳見本項末規格段。 + 執行範圍:`cypher-executor/src/routes/portal-data.ts`(補傳 kbdb_base)。 + +- [x] **t117 三元組寫入靜默失敗——FOREACH 全失敗浮出水面(2026-07-29,任務層小改)**: + 診斷:youlin 實例 triplet=0,懷疑 post_triplet http_request 401 被靜默吞掉; + FOREACH 收集到全 `success:false` 的 iterResults 後回 `{success:true, data:{results:[...]}}` —— 呼叫端不知失敗。 + 兩層修法: + ① wasi-shim.ts http_request catch:改寫錯誤 envelope(含 fetch 原始 message)到 WASM 輸出, + 取代只 return 1(WASM 寫 "HTTP request failed"); + ② graph-executor.ts FOREACH:若全部 iterResults 均 `success===false`,拋 Error(含首項 status code) + → catch 轉 ExecutionError → executeWebhookGraph 回 `{success:false, error:"..."}`。 + 執行範圍:`cypher-executor/src/lib/wasi-shim.ts`、`cypher-executor/src/graph-executor.ts`。 + 測試:vitest 新增兩條(FOREACH 全失敗→error 含 status;wasi-shim catch 路徑)。 + +- [x] **t122 萃取引擎金鑰雲端下發(2026-07-29)**: + 來源=總管確認:daemon/config 回傳 extractor:'claude' 寫死且不含金鑰,封測者萃取全滅。 + 修:① `POST /portal/admin/extractor`(admin 閘)存 `{engine, gemini_api_key?, llm_model?}` 到 KV + key=`{tenant}:portal:extractor_config`,金鑰不落 log; + ② `GET /portal/admin/extractor`(回 engine + has_key:bool,不回明文); + ③ `POST /portal/daemon/config` 回傳改讀上述設定(未設→預設 gemma;gemma+有 key → 含金鑰下發); + ④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊(引擎選擇+金鑰輸入+提示重連)。 + 測試:vitest 新增 3 案(未設定→下發 gemma 無金鑰;設定後→下發含金鑰;GET 不回明文)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)