t176:雲端不再管地端 LLM 設定(leo 08-03 緊急)

leo 回報四件事:①雲端 Gemini 無法用 ②搞不清楚設定 AI 是設雲端還是地端
③地端不會萃、每個人都死掉因為都去抓 Claude ④雲端還要設置明明就有 Workers AI。
目標:「雲端裝好就能用,地端輸一個 Gemini API Key,然後就開始用。」

根因:extractor_config 的 KV key 由 portalTenant() 組出,而 portalTenant 是
**worker 層級**環境變數(portal.ts:43)⇒ **全租戶共用一把**。任一處設了 claude,
所有人的 daemon 都收到 claude;沒裝 Claude Code 的機器萃取全滅,而 portal 的
Claude 勾選框又恆 disabled(daemon 從未實作 report-capabilities ⇒ daemon_caps 永遠空)
⇒ 用戶自己解不開(awindhon 08-03 實證:雲端同步成功、金鑰有效、零張卡)。

本次(雲端側):
- POST /portal/daemon/config **不再下發** extractor/gemini_api_key/llm_model,
  只回連線欄位。⚠️ route 本身與 daemon/libraries 資料夾管理完全不動(leo 明確劃界:
  「雲端拉地端檔案夾部分不要刪,刪掉從雲端設置地端 LLM 選項部分」)。
- 移除 POST|GET /portal/admin/extractor(t122)——這是「雲端指定地端引擎」的入口,
  且無任何伺服器端驗證,打一下就能把全租戶設成 claude。
- 移除 POST /portal/daemon/report-capabilities(t131)——daemon 端從未實作該呼叫。
- 移除 t131 那組重複註冊的 /portal/admin/ai。**它是重複 route**:後段(arcrun-rag#10)
  另有同路徑一組,Hono 先到先比 ⇒ 舊的一直贏,後段修好的「金鑰真的寫進 credentials」
  形同死碼。保留後段那組(只管 Gemini 金鑰、走 storeCredential),並拿掉 Claude 偏好欄。
- portal 設定頁「AI 設定」整塊移除,改成一句話說明:雲端不需設定(Workers AI 免金鑰),
  地端請在同步小幫手的「AI 設定…」填 Gemini Key。順手清掉 main 上既有的 conflict 標記。
- 清掉隨之孤兒化的 helper(ExtractorConfig/AiConfig/DaemonCapabilities/
  syncExtractorFromAiConfig/readAiPref 等)。

測試:**相對 merge 前 main 基準線,新增失敗 = 0**(基準線本就 9 紅:console 藏書地圖 6/
portal HTML 殼 2/POST execute 1,皆與本次無關)。t122/t131 兩組測試改寫成
**回歸守衛**(斷言那些端點/欄位確實 404、確實不存在),不是刪掉充綠。
順手修 health bundle_version 測試與實作對齊(實作刻意省略該欄,測試卻期待空字串)。

未送達:本 commit 只到 code,尚未部署上線。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-03 22:11:45 +08:00
parent eee3f93815
commit 66f1b59f7f
5 changed files with 124 additions and 633 deletions
+51 -264
View File
@@ -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 手法)
// 金鑰不落 logGET 只回 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<ExtractorConfig | null> {
const raw = await env.WEBHOOKS.get(extractorConfigKey(env), 'text');
if (!raw) return null;
try { return JSON.parse(raw) as ExtractorConfig; } catch { return null; }
}
// t176t122 的 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';
// t176leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
//
// 為什麼拔掉:extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`
// 而 portalTenant 是 **worker 層級**環境變數(見本檔 43 行)=**全租戶共用一把**。
// 任一處設了 claude,所有人的 daemon 都會收到 claude;沒裝 Claude Code 的機器
// 會萃取全滅,而 portal 的 Claude 勾選框又恆為 disableddaemon 從未回報 has_claude
// ⇒ 用戶自己解不開(08-03 封測實證:雲端同步成功、金鑰有效,卻零張卡)。
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。
//
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_urlnamespacelibrary)與本 route 本身照舊,
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
const daemonCfg: Record<string, string> = {
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 同時設 chatextractorhas_claude 由 daemon 回報)─────
// KV key = {tenant}:portal:ai_config,存在 WEBHOOKS KV
// KV key = {tenant}:portal:daemon_caps,存 daemon 回報的能力(TTL 7 天)
// t176t131 這一版 `/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<AiConfig | null> {
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<DaemonCapabilities | null> {
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_configdaemon/config 讀 extractor_config,保持相容)。
async function syncExtractorFromAiConfig(env: Bindings, cfg: AiConfig): Promise<void> {
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);
// 更新 chatrag_chat workflow)——容忍 404workflow 未安裝時暫存,安裝後再寫入)
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<string, unknown>;
const visit = (o: unknown): void => {
if (Array.isArray(o)) { o.forEach(visit); return; }
if (o && typeof o === 'object') {
const rec = o as Record<string, unknown>;
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_configdaemon/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_availablet131)。
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 — 庫目錄列表。
// t52leo 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 };
}
}
// - t176Claude 偏好(use_claude_for_extractclaude_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_availableuse_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 keyrole=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 });
}),
);
+6 -4
View File
@@ -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 () => {
+18 -26
View File
@@ -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<string, unknown>;
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_availableuse_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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(d.use_claude_for_extract).toBe(true);
expect(d.success).toBeUndefined();
});
});
+34 -190
View File
@@ -538,13 +538,20 @@ describe('DELETE /portal/admin/librariest135', () => {
});
});
// ═══════════════ 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 勾選框又恆 disableddaemon 從未回報 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 lookupfindUserRecordId 走這個路徑)*/
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<string, string> };
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<string, unknown> };
// 連線欄位照舊(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<string, string> };
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 /portalP4 admin 頁 HTML 殼)', () => {
});
});
// ═══════════════ 8. t131 合併 AI 設定 ═══════════════
describe('/portal/admin/ai + /portal/daemon/report-capabilitiest131', () => {
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<string, string> {
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_configextractor_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<string, string> }> }).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=trueextractor 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=trueclaude_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 — 有 claudedaemon_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);
});
});