diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index bfa059f..9e8d9f3 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -347,36 +347,32 @@
封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
-
-
AI 問答金鑰
-
用來啟用「問 AI」。到 aistudio.google.com 免費申請。金鑰只存在你自己的知識庫裡。
-
- - -
-
-
- -
-
萃取引擎
-
同步小幫手把你的文件變成知識卡時用的 AI。選好後請讓小幫手重連一次。
-
-
- - + +
+
AI 設定
+
+ +
+
Gemini API Key
+
聊天問答與文件萃取都用這一把。免費申請,金鑰只存在你自己的知識庫裡。
+
-
-
aistudio.google.com/apikey 免費取得金鑰
- + +
+
讓知識卡整理得更好(選填)
+ +
+ 讀你文件、整理成知識卡的那個 AI,換成更強的模型。卡片會更抓得到重點、關聯也連得更準,之後搜尋和問答的品質跟著提升。需要你的電腦已安裝 Claude Code。不填就用上面那把 Gemini,一樣能用。 +
+ +
+
+ +
- -
@@ -788,97 +784,72 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling); } } - var kb = $('st-key-save'); - if (kb) kb.addEventListener('click', function () { - var k = ($('st-key').value || '').trim(); - var m = $('st-key-status'); - if (!k) { m.textContent = '請先貼上金鑰'; m.style.color = ''; return; } - kb.disabled = true; m.textContent = '儲存中…'; m.style.color = ''; - fetch(API_BASE + '/portal/admin/chat-key', { - method: 'POST', - headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), - body: JSON.stringify({ key: k }) - }).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) - .then(function (x) { - kb.disabled = false; - if (guard401(x.status)) return; - // t75 ①:404=這個實例的伺服器版本還沒有這個功能(不是使用者做錯)。 - // 講清楚「為什麼」與「怎麼辦」,否則他只看到「儲存失敗」會反覆重試同一件事。 - if (x.status === 404) { - m.textContent = '你的知識庫版本還沒有這個功能,請先更新知識庫再回來設定金鑰'; - m.style.color = '#b4462f'; return; - } - if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; } - $('st-key').value = ''; - m.textContent = '已儲存,AI 問答可以用了'; m.style.color = '#3f7a4f'; - }) - .catch(function (e) { kb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; }); - }); - })(); + // t131 AI 設定(合併 Gemini API Key+Claude 加強版) + (function () { + // 進入設定頁時讀取現有設定(GET /portal/admin/ai) + function loadAiConfig() { + if (!(S.profile && S.profile.role === 'admin')) return; + fetch(API_BASE + '/portal/admin/ai', { headers: authHeaders() }) + .then(function (r) { return r.ok ? safeJson(r) : null; }) + .then(function (d) { + if (!d) return; + var ki = $('st-ai-key'); + if (ki && d.has_key) ki.placeholder = '已設定(留空=不變更)'; + var cb = $('st-ai-use-claude'); + if (cb) { + // 有 claude 才能勾;沒有則停用並顯示提示 + var hint = $('st-ai-claude-hint'); + if (d.claude_available) { + cb.disabled = false; + cb.checked = !!d.use_claude_for_extract; + if (hint) hint.style.display = 'none'; + } else { + cb.disabled = true; + cb.checked = false; + if (hint) { + hint.style.display = ''; + // 區分:從未連上小幫手 vs 有連上但沒裝 Claude + hint.textContent = d.has_key + ? '你的電腦沒有偵測到 Claude Code;裝好並讓小幫手重連一次後,這個選項就會開啟。' + : '連上小幫手後才知道你的電腦有沒有 Claude Code。'; + } + } + } + }) + .catch(function () { /* 讀不到不擋頁面 */ }); + } + window._loadAiConfig = loadAiConfig; - // 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; - })(); + // 儲存 + var sb = $('st-ai-save'); + if (sb) sb.addEventListener('click', function () { + var m = $('st-ai-status'); + var k = (($('st-ai-key') && $('st-ai-key').value) || '').trim(); + var cb = $('st-ai-use-claude'); + var useClaud = cb && !cb.disabled ? cb.checked : undefined; + var body = {}; + if (k) body.gemini_api_key = k; + if (useClaud !== undefined) body.use_claude_for_extract = useClaud; + sb.disabled = true; m.textContent = '儲存中…'; m.style.color = ''; + fetch(API_BASE + '/portal/admin/ai', { + 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-ai-key')) { $('st-ai-key').value = ''; $('st-ai-key').placeholder = '已設定(留空=不變更)'; } + var claudeOn = x.d && x.d.use_claude_for_extract; + m.textContent = claudeOn + ? '已儲存,萃取改用 Claude Code。小幫手請重連一次生效。' + : '已儲存,AI 問答與萃取皆可使用。'; + m.style.color = '#3f7a4f'; + }) + .catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; }); + }); + })(); $('st-logout').addEventListener('click', function () { fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ }); @@ -973,15 +944,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var m = document.getElementById('sc-key-msg'); if (!k) { m.textContent = '請先貼上金鑰'; return; } kb.disabled = true; m.textContent = '啟用中…'; - fetch(API_BASE + '/portal/admin/chat-key', { + fetch(API_BASE + '/portal/admin/ai', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), - body: JSON.stringify({ key: k }) + body: JSON.stringify({ gemini_api_key: k }) }).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { kb.disabled = false; - // t75 ①:同設定頁——404=實例版本太舊,不是使用者的錯。 - if (x.status === 404) { m.textContent = '你的知識庫版本還沒有這個功能,請先更新知識庫'; return; } if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; } markStep('key'); }) @@ -1735,7 +1704,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(); + if (window._loadAiConfig) window._loadAiConfig(); 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 cf3ac55..3220318 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -838,10 +838,149 @@ portalRouter.post('/portal/daemon/config', (c) => }), ); -// POST /portal/admin/chat-key — body {key}。站內啟用 AI 問答(t53,leo 07-25: -// 「他進到網站要去給 gemini API Key」——不再回安裝器)。手法=G10 直嵌同款: -// 改寫 tenant 的 rag_chat workflow 記錄,把 x-goog-api-key 換成實值(KV 覆寫=重推)。 -// 金鑰只過境不落 log;admin 閘(與庫登記同級)。 +// ── t131 合併 AI 設定(Gemini API Key 同時設 chat+extractor;has_claude 由 daemon 回報)───── +// KV key = {tenant}:portal:ai_config,存在 WEBHOOKS KV。 +// KV key = {tenant}:portal:daemon_caps,存 daemon 回報的能力(TTL 7 天)。 + +interface AiConfig { + gemini_api_key?: string; + use_claude_for_extract?: boolean; +} +interface DaemonCapabilities { + has_claude: boolean; + daemon_version?: string; + os?: string; +} + +function aiConfigKey(env: Bindings): string { return `${portalTenant(env)}:portal:ai_config`; } +function daemonCapsKey(env: Bindings): string { return `${portalTenant(env)}:portal:daemon_caps`; } + +async function getAiConfig(env: Bindings): Promise { + const raw = await env.WEBHOOKS.get(aiConfigKey(env), 'text'); + if (!raw) return null; + try { return JSON.parse(raw) as AiConfig; } catch { return null; } +} +async function getDaemonCaps(env: Bindings): Promise { + const raw = await env.WEBHOOKS.get(daemonCapsKey(env), 'text'); + if (!raw) return null; + try { return JSON.parse(raw) as DaemonCapabilities; } catch { return null; } +} + +// 將 ai_config 同步回 extractor_config(daemon/config 讀 extractor_config,保持相容)。 +async function syncExtractorFromAiConfig(env: Bindings, cfg: AiConfig): Promise { + const exCfg: ExtractorConfig = { + engine: cfg.use_claude_for_extract ? 'claude' : 'gemma', + }; + if (!cfg.use_claude_for_extract && cfg.gemini_api_key) { + exCfg.gemini_api_key = cfg.gemini_api_key; + } + await env.WEBHOOKS.put(extractorConfigKey(env), JSON.stringify(exCfg)); +} + +// POST /portal/admin/ai — body {gemini_api_key?, use_claude_for_extract?}(t131)。 +// 同時設定 AI 問答金鑰(chat)與萃取引擎(extractor)。admin 閘。 +portalRouter.post('/portal/admin/ai', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = (await c.req.json().catch(() => null)) as { gemini_api_key?: string; use_claude_for_extract?: boolean } | null; + const newKey = String(body?.gemini_api_key ?? '').trim(); + const useClause = typeof body?.use_claude_for_extract === 'boolean' ? body.use_claude_for_extract : undefined; + + // 讀現有設定做合併(留空欄位=不變更) + const existing = await getAiConfig(c.env) ?? {}; + const merged: AiConfig = { + gemini_api_key: newKey || existing.gemini_api_key, + use_claude_for_extract: useClause !== undefined ? useClause : (existing.use_claude_for_extract ?? false), + }; + if (!merged.gemini_api_key) return c.json({ error: '請貼上你的 Gemini API Key' }, 400); + + // 更新 chat(rag_chat workflow)——容忍 404(workflow 未安裝時暫存,安裝後再寫入) + if (newKey) { + const tenant = portalTenant(c.env); + const kvKey = `${tenant}:wf:rag_chat`; + const raw = await c.env.WEBHOOKS.get(kvKey, 'text'); + if (raw) { + try { + const record = JSON.parse(raw) as Record; + const visit = (o: unknown): void => { + if (Array.isArray(o)) { o.forEach(visit); return; } + if (o && typeof o === 'object') { + const rec = o as Record; + for (const k of Object.keys(rec)) { + if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = newKey; } + else visit(rec[k]); + } + } + }; + visit(record['graph']); + visit(record['config']); + await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record)); + } catch { /* 工作流記錄損壞時靜默略過,金鑰仍存 ai_config */ } + } + // 若 rag_chat 不存在(raw===null),跳過,等 acr init 安裝後再用舊 chat-key 端點補入 + } + + // 存合併設定 + await c.env.WEBHOOKS.put(aiConfigKey(c.env), JSON.stringify(merged)); + // 同步回 extractor_config(daemon/config 走這個) + await syncExtractorFromAiConfig(c.env, merged); + + return c.json({ + success: true, + has_key: true, + use_claude_for_extract: merged.use_claude_for_extract ?? false, + }); + }), +); + +// GET /portal/admin/ai — 回 has_key/use_claude_for_extract/claude_available(t131)。 +portalRouter.get('/portal/admin/ai', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const cfg = await getAiConfig(c.env); + const caps = await getDaemonCaps(c.env); + return c.json({ + success: true, + has_key: !!(cfg?.gemini_api_key), + use_claude_for_extract: cfg?.use_claude_for_extract ?? false, + claude_available: caps?.has_claude ?? false, + }); + }), +); + +// POST /portal/daemon/report-capabilities — body {email, password, has_claude, daemon_version?, os?}(t131)。 +// daemon 連線成功後回報本機能力;認證同 /portal/daemon/config(帳密)。 +// ⚠️ daemon 端改動屬 arcrun-rag repo,本端只做「收端點+存 KV+供 GET /portal/admin/ai 用」。 +portalRouter.post('/portal/daemon/report-capabilities', (c) => + run(c, async () => { + const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string; has_claude?: boolean; daemon_version?: string; os?: string } | null; + const email = String(body?.email ?? '').trim().toLowerCase(); + const password = String(body?.password ?? ''); + if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); + if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多', }, 429); + const recordId = await findUserRecordId(c.env, email); + const rec = recordId ? await getRecordById(c.env, recordId) : null; + if (!rec) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } + if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403); + if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) { + await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); + } + await clearLoginFail(c.env, email); + const caps: DaemonCapabilities = { + has_claude: body?.has_claude === true, + ...(body?.daemon_version ? { daemon_version: String(body.daemon_version) } : {}), + ...(body?.os ? { os: String(body.os) } : {}), + }; + const TTL_7D = 7 * 24 * 60 * 60; + await c.env.WEBHOOKS.put(daemonCapsKey(c.env), JSON.stringify(caps), { expirationTtl: TTL_7D }); + return c.json({ success: true }); + }), +); + +// POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。 +// 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。 portalRouter.post('/portal/admin/chat-key', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); @@ -881,6 +1020,7 @@ portalRouter.post('/portal/admin/chat-key', (c) => ); // POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}(t122)。 +// 保留舊端點相容(新 UI 走 /portal/admin/ai)。 // admin 閘(同 chat-key 等級)。金鑰不落 log;存 WEBHOOKS KV。 portalRouter.post('/portal/admin/extractor', (c) => run(c, async () => { @@ -904,6 +1044,7 @@ portalRouter.post('/portal/admin/extractor', (c) => ); // GET /portal/admin/extractor — 回 engine + has_key(不回金鑰明文)(t122)。 +// 保留舊端點相容(新 UI 走 /portal/admin/ai)。 portalRouter.get('/portal/admin/extractor', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 88ce2a1..55ffbc1 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -556,5 +556,165 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => { // t114:無「登記到目錄」按鈕 expect(html).not.toContain('lib-adopt'); expect(html).not.toContain('登記到目錄'); + // t131:合併 AI 設定(舊兩區塊已移除) + expect(html).toContain('st-ai-panel'); + expect(html).toContain('st-ai-key'); + expect(html).toContain('st-ai-use-claude'); + expect(html).not.toContain('st-extractor-panel'); + expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除 + }); +}); + +// ═══════════════ 8. t131 合併 AI 設定 ═══════════════ + +describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () => { + const USER_EMAIL = 'ai-test@example.com'; + const USER_PW = 'unit-test-pw-1'; + const USER_RECORD = 'rec_ai_user'; + const AI_CONFIG_KEY = 'leo:portal:ai_config'; + const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; + const DAEMON_CAPS_KEY = 'leo:portal:daemon_caps'; + + function aiAdminVals(): Record { + return { email: USER_EMAIL, display_name: 'AI 測試 admin', status: 'active', role: 'admin', password_hash: storedHash }; + } + + // 與全域 seedAdminSession 相同格式(JSON.stringify({record_id})),fetchMock 由各測試自行 mock + async function seedAiSession(token = 'tok-ai-admin', recordId = USER_RECORD) { + await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); + } + + function mockAiRecord(recordId = USER_RECORD) { + fetchMock.get(KBDB).intercept({ path: `/records/${recordId}`, method: 'GET' }).reply(200, { + success: true, + record: { record_id: recordId, template_id: 'tpl_pu', values: aiAdminVals() }, + }); + } + + 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 }); + } + + afterEach(async () => { + await env.WEBHOOKS.delete(AI_CONFIG_KEY); + await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); + await env.WEBHOOKS.delete(DAEMON_CAPS_KEY); + }); + + it('POST /ai — 首次設定:同時寫 ai_config+extractor_config+更新 rag_chat workflow', async () => { + const ragChatKey = 'leo:wf:rag_chat'; + const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': '{{credential.gemini}}' } }] }, config: {} }; + await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow)); + await seedAiSession(); + mockAiRecord(); + const res = await json('POST', '/portal/admin/ai', + { gemini_api_key: 'AIza-new-key-123', use_claude_for_extract: false }, + { Authorization: 'Bearer tok-ai-admin' } + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean }; + expect(data.success).toBe(true); + expect(data.has_key).toBe(true); + expect(data.use_claude_for_extract).toBe(false); + + const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}'); + expect(stored.gemini_api_key).toBe('AIza-new-key-123'); + expect(stored.use_claude_for_extract).toBe(false); + + const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}'); + expect(exCfg.engine).toBe('gemma'); + expect(exCfg.gemini_api_key).toBe('AIza-new-key-123'); + + const updated = JSON.parse((await env.WEBHOOKS.get(ragChatKey, 'text')) ?? '{}') as typeof workflow; + expect((updated.graph as { nodes: Array<{ config: Record }> }).nodes[0].config['x-goog-api-key']).toBe('AIza-new-key-123'); + await env.WEBHOOKS.delete(ragChatKey); + }); + + it('POST /ai — rag_chat 不存在時不報錯(容忍,金鑰存 ai_config 即可)', async () => { + await seedAiSession(); + mockAiRecord(); + const res = await json('POST', '/portal/admin/ai', + { gemini_api_key: 'AIza-no-workflow-key' }, + { Authorization: 'Bearer tok-ai-admin' } + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; has_key: boolean }; + expect(data.success).toBe(true); + expect(data.has_key).toBe(true); + const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}'); + expect(stored.gemini_api_key).toBe('AIza-no-workflow-key'); + }); + + it('POST /ai — use_claude_for_extract=true:extractor engine=claude,不附 gemini_api_key', async () => { + await seedAiSession(); + mockAiRecord(); + const res = await json('POST', '/portal/admin/ai', + { gemini_api_key: 'AIza-key-888', use_claude_for_extract: true }, + { Authorization: 'Bearer tok-ai-admin' } + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; use_claude_for_extract: boolean }; + expect(data.use_claude_for_extract).toBe(true); + const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}'); + expect(exCfg.engine).toBe('claude'); + expect('gemini_api_key' in exCfg).toBe(false); + }); + + it('GET /ai — 不回明文金鑰;has_key=true;claude_available 依 daemon_caps', async () => { + await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-secret-456', use_claude_for_extract: false })); + await env.WEBHOOKS.put(DAEMON_CAPS_KEY, JSON.stringify({ has_claude: true })); + await seedAiSession(); + mockAiRecord(); + const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean; claude_available: boolean }; + expect(data.has_key).toBe(true); + expect(data.use_claude_for_extract).toBe(false); + expect(data.claude_available).toBe(true); + const raw = JSON.stringify(data); + expect(raw).not.toContain('AIza-secret-456'); + expect(raw).not.toContain('gemini_api_key'); + }); + + it('GET /ai — 沒有 daemon_caps → claude_available=false', async () => { + await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-key-777' })); + await seedAiSession(); + mockAiRecord(); + const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { claude_available: boolean }; + expect(data.claude_available).toBe(false); + }); + + it('POST /portal/daemon/report-capabilities — 有 claude:daemon_caps 寫入 has_claude=true', async () => { + mockEmailLookup(USER_EMAIL, USER_RECORD); + mockAiRecord(); + const res = await json('POST', '/portal/daemon/report-capabilities', { + email: USER_EMAIL, password: USER_PW, has_claude: true, daemon_version: '1.2.0', os: 'darwin', + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean }; + expect(data.success).toBe(true); + const caps = JSON.parse((await env.WEBHOOKS.get(DAEMON_CAPS_KEY, 'text')) ?? '{}'); + expect(caps.has_claude).toBe(true); + expect(caps.daemon_version).toBe('1.2.0'); + }); + + it('舊端點 /portal/admin/chat-key 仍可用(相容)', async () => { + const ragChatKey = 'leo:wf:rag_chat'; + const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': 'old' } }] }, config: {} }; + await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow)); + await seedAiSession(); + mockAiRecord(); + const res = await json('POST', '/portal/admin/chat-key', { key: 'AIza-compat-key' }, { Authorization: 'Bearer tok-ai-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; replaced: number }; + expect(data.success).toBe(true); + expect(data.replaced).toBeGreaterThan(0); + await env.WEBHOOKS.delete(ragChatKey); }); }); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index bab03c6..da48a2b 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -290,6 +290,21 @@ 移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。 測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403;by-name 成功/無confirm/confirm不符/非admin403)。 +- [x] **t131 金鑰設定合併(2026-07-29,任務層小改)**: + 來源=leo 裁定「一律規定先輸入 gemini api key,聊天和萃都一次設好;如果有 claude,勾選加強版」。 + 後端:新增 `POST /portal/admin/ai`(同時做 chat-key+extractor 的事)+ + `GET /portal/admin/ai`(回 has_key/use_claude_for_extract/claude_available)+ + `POST /portal/daemon/report-capabilities`(收 daemon 回報的 has_claude); + 舊 `/portal/admin/chat-key`、`/portal/admin/extractor` 保留相容。 + 前端:設定頁兩區塊合一,Gemini Key 必填欄+Claude 選填 checkbox(依 daemon 回報 enable/disable)。 + ⚠️ daemon 端(arcrun-rag repo)需實作 `report-capabilities` 呼叫,規格: + - 連線成功後 POST `{cypher_url}/portal/daemon/report-capabilities` + - body: `{email, password, has_claude: bool, daemon_version: string, os: string}` + - has_claude 由現有 `FindClaudeBin()` 決定(t92 已實作) + - 時機:連線成功後一次+每次啟動時一次(不加新 timer) + KV key 設計:`{tenant}:portal:ai_config`(合併設定)`{tenant}:portal:daemon_caps`(能力回報,TTL 7 天) + 測試:portal-admin.test.ts 新增 7 案(全通;全套 238 tests 229 passed,9 failed 皆 pre-existing)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)