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);
- });
-});