diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index dcfd1a7..4649f3b 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -363,44 +363,18 @@ if (!window.ARCRUN_API_BASE) {
封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
- -
+ +
AI 設定
-
- -
-
Gemini API Key
-
聊天問答與文件萃取都用這一把。免費申請,金鑰只存在你自己的知識庫裡。
-<<<<<<< HEAD - - -======= ->>>>>>> main - -
- -
-
讓知識卡整理得更好(選填)
- -
- 讀你文件、整理成知識卡的那個 AI,換成更強的模型。卡片會更抓得到重點、關聯也連得更準,之後搜尋和問答的品質跟著提升。需要你的電腦已安裝 Claude Code。不填就用上面那把 Gemini,一樣能用。 -
- -
-
- -
-
+
+ 這裡不需要任何設定。聊天問答用的是你自己 Cloudflare 帳號內建的 AI,裝好就能直接問。
+ 文件整理成知識卡的部分,請在同步小幫手(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。
@@ -433,11 +407,7 @@ if (!window.ARCRUN_API_BASE) {
-<<<<<<< HEAD - 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。
@@ -826,114 +796,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { } })(); - // t131 AI 設定(合併 Gemini API Key+Claude 加強版) - (function () { -<<<<<<< HEAD - /** - * 切換金鑰欄的「已存 / 可輸入」兩態(leo 2026-08-01 規格,arcrun-rag#10): - * 已存 → 唯讀顯示「已輸入」+「修改」鈕(**不顯示 key 本身**,D36) - * 未存 / 按了修改 → 一般輸入框 - * 為什麼要這樣:舊版只把 placeholder 改成「已設定」=看起來像臨時提示, - * 用戶讀不出「這是已保存的記錄」⇒ 以為沒存到、反覆重填。 - */ - function setAiKeySaved(saved) { - var box = $('st-ai-key-saved'); - var input = $('st-ai-key'); - if (!box || !input) return; - if (saved) { - box.style.display = 'flex'; - input.style.display = 'none'; - input.value = ''; - } else { - box.style.display = 'none'; - input.style.display = ''; - } - } - // 按「修改」→ 變回可輸入框並聚焦(留空送出=不變更,由後端 body 判斷) - var editBtn = $('st-ai-key-edit'); - if (editBtn) editBtn.addEventListener('click', function () { - setAiKeySaved(false); - var input = $('st-ai-key'); - if (input) { input.placeholder = '貼上新的 Gemini API Key(留空取消變更)'; input.focus(); } - }); - -======= ->>>>>>> main - // 進入設定頁時讀取現有設定(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; -<<<<<<< HEAD - // leo 2026-08-01 規格:已存 → 唯讀「已輸入」+「修改」鈕;未存 → 一般輸入框 - setAiKeySaved(!!d.has_key); -======= - var ki = $('st-ai-key'); - if (ki && d.has_key) ki.placeholder = '已設定(留空=不變更)'; ->>>>>>> main - 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; - - // 儲存 - 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; } -<<<<<<< HEAD - // 存好了才切回「已輸入」唯讀態。只有真的送了新 key 才切; - // 單純改 Claude 勾選(k 為空)時維持現狀,不誤報「已輸入」。 - if (k) setAiKeySaved(true); -======= - if ($('st-ai-key')) { $('st-ai-key').value = ''; $('st-ai-key').placeholder = '已設定(留空=不變更)'; } ->>>>>>> main - 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'; }); - }); - })(); + // t176(leo 08-03):AI 設定的前端邏輯整段移除。 + // 雲端聊天走 Workers AI(免金鑰);地端萃取金鑰改由同步小幫手托盤「AI 設定…」自己設。 + // 原本這裡有 Claude 勾選框的 enable/disable 邏輯——它依賴 daemon 回報 has_claude, + // 而 daemon 從未實作該回報 ⇒ 勾選框恆為 disabled(leo 實測「hover 變可點但點擊沒反應」)。 $('st-logout').addEventListener('click', function () { fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ }); diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 31a6a33..82d4f26 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -775,26 +775,9 @@ 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; } -} +// t176:t122 的 extractor_config(雲端指定地端萃取引擎)整組移除—— +// interface/KV key/讀取函式都不再需要,因為雲端已不下發、也不再有設定入口。 +// ⚠️ 舊實例的 KV 殘值無害:daemon 端 t176 起也不吃這個欄位了。 // POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」 // 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)—— @@ -823,164 +806,42 @@ portalRouter.post('/portal/daemon/config', (c) => } await clearLoginFail(c.env, email); const tenant = portalTenant(c.env); - const extractorCfg = await getExtractorConfig(c.env); - const engine = extractorCfg?.engine ?? 'gemma'; + // t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。 + // 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。 + // + // 為什麼拔掉:extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`, + // 而 portalTenant 是 **worker 層級**環境變數(見本檔 43 行)=**全租戶共用一把**。 + // 任一處設了 claude,所有人的 daemon 都會收到 claude;沒裝 Claude Code 的機器 + // 會萃取全滅,而 portal 的 Claude 勾選框又恆為 disabled(daemon 從未回報 has_claude) + // ⇒ 用戶自己解不開(08-03 封測實證:雲端同步成功、金鑰有效,卻零張卡)。 + // leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。 + // + // ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊, + // daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。 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 }); }), ); -// ── 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 天)。 +// t176:t131 這一版 `/portal/admin/ai`(POST/GET)**整組移除**。兩個原因: +// ① 它是**重複註冊**——本檔後段(arcrun-rag#10 那版)另有一組同路徑 route。 +// Hono 先到先比 ⇒ 舊的這組一直贏,後段那組修好的「金鑰真的寫進 credentials」形同死碼。 +// 這正是「key 從來沒存進去」的 bug 在 merge 後仍可能復發的原因。 +// ② 它把 use_claude_for_extract 同步進 extractor_config 下發給 daemon +// (syncExtractorFromAiConfig),而那把 KV 是**全租戶共用**——正是 08-03 事故根因。 +// 保留的是後段那組(只管 Gemini 金鑰,走 storeCredential 唯一寫入路徑,不碰 extractor)。 -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 }); - }), -); +// t176:`POST /portal/daemon/report-capabilities` 已移除。 +// 它的用途是收 daemon 回報的 has_claude 去解鎖 portal 的 Claude 勾選框; +// 但 daemon 端從未實作這個呼叫(arcrun-rag 全庫 grep = 0 命中), +// 導致 daemon_caps KV 永遠空、勾選框恆 disabled。t176 起地端模型由小幫手自己設, +// 這條回報鏈整條不需要了。 // POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。 // 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。 @@ -1022,45 +883,11 @@ 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 () => { - 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)。 -// 保留舊端點相容(新 UI 走 /portal/admin/ai)。 -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, - }); - }), -); +// t176:`POST|GET /portal/admin/extractor`(t122)已移除。 +// 這是「雲端指定地端萃取引擎」的舊入口,且**沒有任何伺服器端驗證**—— +// 只要打這條就能把 extractor_config 設成 claude,而那把 KV 全租戶共用 +// ⇒ 所有沒裝 Claude Code 的機器萃取全滅(08-03 事故)。 +// 地端模型現由同步小幫手托盤「AI 設定…」自己設,雲端不再有這個概念。 // GET /portal/admin/libraries — 庫目錄列表。 // t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」): @@ -1250,23 +1077,8 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) => // - **永不回傳 key 本身**(D36):GET 只回 `has_key` 布林。 // - credential 的 `api_key` 欄=租戶 slug(`portalTenant`),與安裝器 seedCredential // 寫 `kbdb_internal_token` 用的 ns 同一個值 ⇒ 兩者落在同一租戶分區,查得到彼此。 -// - `use_claude_for_extract` 存 KV(單一布林偏好,不值得為它開 KBDB template)。 -// - `claude_available` 由小幫手(daemon)回報;目前後端無此訊號 ⇒ **誠實回 false**, -// 不假裝有(前端已有對應文案:「連上小幫手後才知道…」)。 - -const AI_PREF_KEY = (tenantSlug: string) => `portal_ai_pref:${tenantSlug}`; - -/** 讀 AI 偏好(KV)。壞掉/沒有一律回預設,不擋頁面。 */ -async function readAiPref(env: Bindings, tenantSlug: string): Promise<{ use_claude_for_extract: boolean }> { - try { - const raw = await env.WEBHOOKS.get(AI_PREF_KEY(tenantSlug)); - if (!raw) return { use_claude_for_extract: false }; - const p = JSON.parse(raw) as { use_claude_for_extract?: boolean }; - return { use_claude_for_extract: !!p.use_claude_for_extract }; - } catch { - return { use_claude_for_extract: false }; - } -} +// - t176:Claude 偏好(use_claude_for_extract/claude_available)已整組移除—— +// 地端用哪個模型由同步小幫手自己設,雲端不再有這個概念。這裡只管 Gemini 金鑰。 // GET /portal/admin/ai — 回 AI 設定現況(role=admin 閘)。**只回 has_key 布林,永不回 key**。 portalRouter.get('/portal/admin/ai', (c) => @@ -1287,15 +1099,9 @@ portalRouter.get('/portal/admin/ai', (c) => hasKey = false; } - const pref = await readAiPref(c.env, tenantSlug); - return c.json({ - success: true, - has_key: hasKey, - // 目前後端沒有「這台電腦有沒有 Claude Code」的訊號來源(該由小幫手回報) - // ⇒ 誠實回 false,前端會顯示「連上小幫手後才知道…」,不假綠。 - claude_available: false, - use_claude_for_extract: pref.use_claude_for_extract, - }); + // t176:不再有 claude_available/use_claude_for_extract——地端用哪個模型 + // 由同步小幫手自己設,雲端不介入(leo 08-03)。 + return c.json({ success: true, has_key: hasKey }); }), ); @@ -1327,51 +1133,32 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) => }), ); -// POST /portal/admin/ai — 存 Gemini key 與/或 Claude 偏好(role=admin 閘)。 -// body: { gemini_api_key?: string, use_claude_for_extract?: boolean } -// 兩者皆選填(前端「留空=不變更」);兩者都沒給 → 400,避免看起來成功但什麼都沒做。 +// POST /portal/admin/ai — 存 Gemini key(role=admin 閘)。body: { gemini_api_key: string } +// t176:Claude 偏好欄位已移除(地端模型由同步小幫手自己設,雲端不下發)。 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?: unknown; use_claude_for_extract?: unknown } - | null; + const body = await c.req.json().catch(() => null) as { gemini_api_key?: unknown } | null; const rawKey = typeof body?.gemini_api_key === 'string' ? body.gemini_api_key.trim() : ''; - const hasPref = typeof body?.use_claude_for_extract === 'boolean'; - if (!rawKey && !hasPref) { - return c.json({ error: '沒有要變更的項目(金鑰留空且未改設定)' }, 400); + if (!rawKey) { + return c.json({ error: '沒有要變更的項目(金鑰留空)' }, 400); } const tenantSlug = portalTenant(c.env); - - if (rawKey) { - try { - // 唯一寫入路徑(credentials.ts):Workers Secret 存值 + D1 存 ref。 - await storeCredential(c.env, tenantSlug, 'gemini_api_key', rawKey, 'gemini'); - } catch (e) { - // 誠實回報寫入失敗——這正是本 bug 的教訓:不能讓前端以為存好了。 - return c.json( - { error: `金鑰儲存失敗:${e instanceof Error ? e.message : String(e)}` }, - 502, - ); - } - } - - if (hasPref) { - await c.env.WEBHOOKS.put( - AI_PREF_KEY(tenantSlug), - JSON.stringify({ use_claude_for_extract: !!body!.use_claude_for_extract }), + try { + // 唯一寫入路徑(credentials.ts):Workers Secret 存值 + D1 存 ref。 + await storeCredential(c.env, tenantSlug, 'gemini_api_key', rawKey, 'gemini'); + } catch (e) { + // 誠實回報寫入失敗——這正是本 bug 的教訓:不能讓前端以為存好了。 + return c.json( + { error: `金鑰儲存失敗:${e instanceof Error ? e.message : String(e)}` }, + 502, ); } - const pref = await readAiPref(c.env, tenantSlug); - return c.json({ - success: true, - has_key: rawKey ? true : undefined, - use_claude_for_extract: pref.use_claude_for_extract, - }); + return c.json({ success: true, has_key: true }); }), ); diff --git a/cypher-executor/tests/health.test.ts b/cypher-executor/tests/health.test.ts index 19d2215..26f4233 100644 --- a/cypher-executor/tests/health.test.ts +++ b/cypher-executor/tests/health.test.ts @@ -4,13 +4,15 @@ import { healthRouter } from '../src/routes/health'; import type { Bindings, ExecutionContext } from '../src/types'; describe('GET /health — bundle_version 欄位', () => { - it('無 ARCRUN_BUNDLE_VERSION 時回空字串(老實例情境)', async () => { - // wrangler.test.toml 不設此 var → 走 ?? '' fallback + it('無 ARCRUN_BUNDLE_VERSION 時省略該欄(老實例情境)', async () => { + // wrangler.test.toml 不設此 var → health.ts 省略 bundle_version 欄位。 + // daemon 端讀不到該欄=當作空字串=判 stale,對老實例而言**這是正確行為** + //(見 health.ts 檔頭註解)。此處驗「省略」而非「回空字串」,與實作對齊。 const res = await SELF.fetch('http://localhost/health'); - const data = await res.json() as { ok: boolean; bundle_version: string }; + const data = await res.json() as { ok: boolean; bundle_version?: string }; expect(res.status).toBe(200); expect(data.ok).toBe(true); - expect(data.bundle_version).toBe(''); + expect(data.bundle_version).toBeUndefined(); }); it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => { diff --git a/cypher-executor/tests/portal-admin-ai.test.ts b/cypher-executor/tests/portal-admin-ai.test.ts index 7759d23..a8a0f3f 100644 --- a/cypher-executor/tests/portal-admin-ai.test.ts +++ b/cypher-executor/tests/portal-admin-ai.test.ts @@ -80,7 +80,7 @@ describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () => }); describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => { - it('回 has_key 布林+claude 旗標,且回應完全不含金鑰值', async () => { + it('回 has_key 布林,且回應完全不含金鑰值', async () => { await seedSession('tok-a1', 'rec_admin'); mockGetRecord('rec_admin', adminValues()); const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1')); @@ -89,8 +89,6 @@ describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () const d = JSON.parse(raw) as Record; expect(typeof d.has_key).toBe('boolean'); - expect(typeof d.claude_available).toBe('boolean'); - expect(typeof d.use_claude_for_extract).toBe('boolean'); // D36:回應裡不得出現任何疑似金鑰的欄位 expect(raw).not.toContain('gemini_api_key_value'); @@ -98,10 +96,22 @@ describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () expect(d).not.toHaveProperty('value'); expect(d).not.toHaveProperty('secret_ref'); }); + + // t176 回歸守衛(leo 08-03):雲端不再有「地端用哪個模型」的概念。 + // 這兩個欄位若復活,代表又走回「雲端控制地端」的老路——那正是 08-03 事故根因 + //(extractor_config 全租戶共用一把,任一處設 claude 就讓所有人萃取全滅)。 + it('不再回 claude_available/use_claude_for_extract(地端模型改由小幫手自己設)', async () => { + await seedSession('tok-a1b', 'rec_admin'); + mockGetRecord('rec_admin', adminValues()); + const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1b')); + const d = (await res.json()) as Record; + expect(d).not.toHaveProperty('claude_available'); + expect(d).not.toHaveProperty('use_claude_for_extract'); + }); }); describe('POST /portal/admin/ai — 不假裝成功', () => { - it('空 body(沒 key 也沒偏好)→ 400,不回 success', async () => { + it('空 body(沒帶金鑰)→ 400,不回 success', async () => { await seedSession('tok-a2', 'rec_admin'); mockGetRecord('rec_admin', adminValues()); const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2')); @@ -111,31 +121,13 @@ describe('POST /portal/admin/ai — 不假裝成功', () => { expect(String(d.error)).toContain('沒有要變更'); }); - it('只改 Claude 偏好(不帶 key)→ 成功並回存後的值', async () => { + // t176 回歸守衛:只送 Claude 偏好=沒有要變更的項目 → 400(該欄位已不存在)。 + it('只送 use_claude_for_extract(已廢欄位)→ 400,不得假裝成功', async () => { await seedSession('tok-a3', 'rec_admin'); mockGetRecord('rec_admin', adminValues()); const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3')); - expect(res.status).toBe(200); + expect(res.status).toBe(400); const d = (await res.json()) as Record; - expect(d.success).toBe(true); - expect(d.use_claude_for_extract).toBe(true); - // 沒送 key ⇒ 不得回報 has_key(避免誤報「已輸入」) - expect(d.has_key).toBeUndefined(); - }); - - // 註:vitest-pool-workers 預設 isolatedStorage=true ⇒ **每個 it 之間 KV 會還原**, - // 所以「寫在上一個 it、讀在下一個 it」測不出來(那是測試框架語意,不是程式缺陷)。 - // 要驗來回一致,必須在**同一個 it** 內完成寫→讀。 - it('偏好可讀回:同一測試內 POST 寫入 → GET 讀得到同一值', async () => { - await seedSession('tok-a4', 'rec_admin'); - mockGetRecord('rec_admin', adminValues()); // POST 的 requirePortalAdmin 回讀 - mockGetRecord('rec_admin', adminValues()); // GET 的 requirePortalAdmin 回讀 - - const post = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a4')); - expect(post.status).toBe(200); - - const get = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a4')); - const d = (await get.json()) as Record; - expect(d.use_claude_for_extract).toBe(true); + expect(d.success).toBeUndefined(); }); }); diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 40c83ec..efc4c39 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -538,13 +538,20 @@ describe('DELETE /portal/admin/libraries(t135)', () => { }); }); -// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════ +// ═══════════════ 6. t176:雲端不再管地端 LLM 設定(取代原 t122/t131 兩組測試)═══════════════ +// +// leo 2026-08-03 架構翻案:「地端要用什麼模型就在 daemon 上輸入 API Key 設置, +// 而不是雲端設置後控制地端」。原因是 extractor_config 的 KV key 由 portalTenant() 組出, +// 而 portalTenant 是 **worker 層級**環境變數 ⇒ **全租戶共用一把**:任一處設了 claude, +// 所有人的 daemon 都收到 claude,沒裝 Claude Code 的機器萃取全滅, +// 而 portal 的 Claude 勾選框又恆 disabled(daemon 從未回報 has_claude)⇒ 用戶自己解不開。 +// +// 以下是**回歸守衛**:這些端點/欄位若復活,代表又走回「雲端控制地端」的老路。 -describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => { +describe('t176:雲端不再下發/設定地端 LLM', () => { const USER_EMAIL = 'daemon@example.com'; - const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算) + 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) { @@ -558,47 +565,37 @@ describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122) .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); + it('POST /portal/daemon/config 只回連線欄位,**不含任何 LLM 欄位**', async () => { 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); + const d = (await res.json()) as { config: Record }; + + // 連線欄位照舊(daemon 靠它上線) + expect(d.config.cypher_url).toBeTruthy(); + expect(d.config.namespace).toBeTruthy(); + expect(d.config.library).toBe('kb'); + + // LLM 欄位一律不下發(t176 核心) + expect(d.config).not.toHaveProperty('extractor'); + expect(d.config).not.toHaveProperty('gemini_api_key'); + expect(d.config).not.toHaveProperty('llm_model'); }); - 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); + // 註:route 不存在 ⇒ 在認證之前就 404,因此不需要(也不能)預先掛 record mock, + // 否則 afterEach 的 assertNoPendingInterceptors 會因「mock 沒被用到」而失敗。 + it('POST /portal/admin/extractor 已移除(雲端不再有指定地端引擎的入口)', async () => { + const res = await json('POST', '/portal/admin/extractor', { engine: 'claude' }, { Authorization: 'Bearer tok-ex' }); + expect(res.status).toBe(404); }); - 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); + it('POST /portal/daemon/report-capabilities 已移除(has_claude 回報鏈整條退役)', async () => { + const res = await json('POST', '/portal/daemon/report-capabilities', { + email: USER_EMAIL, password: USER_PW, has_claude: true, + }); + expect(res.status).toBe(404); }); }); @@ -632,156 +629,3 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => { }); }); -// ═══════════════ 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); - }); -});