修 arcrun-rag#10:補 /portal/admin/ai(key 從來沒存進去的真因)+UI 已輸入態
真因(不是重裝洗掉,是從來沒存進去):前端唯一寫入路徑 POST /portal/admin/ai
**後端從來沒有這條 route** ⇒ 用戶填 key → 404 → 什麼都沒存,畫面卻像成功了(藍字=假綠)。
產物層鐵證:bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=0;兩實例實打皆 404。
反證(別改不壞的東西):走正確端點寫入 → 完整重裝 24/24 → credential 仍在、
rag_chat verdict failed→success ⇒ **重裝不洗 credential,資料層本來就是保留式的**。
① cypher 補 GET|POST /portal/admin/ai(routes/portal.ts)
- POST 內部轉呼 credentials.ts 既有 storeCredential()=**唯一寫入路徑**,不另造第二套
- GET 只回 has_key 布林,**永不回傳 key 本身**(D36)
- credential 的 api_key 欄=租戶 slug(portalTenant),與安裝器 seedCredential 寫
kbdb_internal_token 用的 ns 同值 ⇒ 同租戶分區,彼此查得到
- use_claude_for_extract 存 KV(單一布林偏好,不為它開 KBDB template)
- claude_available 目前無訊號來源(該由小幫手回報)⇒ **誠實回 false 不假綠**
- 寫入失敗回 502 帶原因——本 bug 的教訓就是「不能讓前端以為存好了」
- 兩者皆未提供 → 400,避免「看起來成功但什麼都沒做」
② 前端(console-ui/public/portal/index.html)
- 拔掉寫死 `|| "https://cypher.arcrun.dev"` fallback ⇒ 缺 config.js 就顯示紅色橫幅明顯報錯。
**寧可明顯失敗,不要靜默把用戶金鑰送去中央實例。**
⚠️ 更正我先前的錯誤診斷:config.js **有**正確注入(實查 leo 實例指向自己的 cypher),
跨租戶錯置未發生;這條是未爆彈不是現行災情。
- leo 規格:已存 → 唯讀「已輸入」+「修改」鈕(不顯示 key);按修改才變回輸入框;
未存 → 一般輸入框。舊版只改 placeholder=讀起來像臨時提示,是它像 UI bug 的原因。
- 只有真的送了新 key 才切回「已輸入」(單純改 Claude 勾選不誤報)
tsc 綠;三個 script 區塊 node --check 全過。
卷:system-dev/docs/3-specs/journeys/gemini-key-lost-on-reinstall.md
This commit is contained in:
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
import { storeCredential } from './credentials';
|
||||
|
||||
export const portalRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -807,3 +810,114 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
|
||||
return c.json({ success: true, library: toPublicLibrary(updated) });
|
||||
}),
|
||||
);
|
||||
|
||||
// ── AI 設定(arcrun-rag#10)────────────────────────────────────────────────────
|
||||
//
|
||||
// 🔴 為什麼這段存在(2026-08-01 真因,別再讓它消失):
|
||||
// 前端設定頁**一直**在打 `GET|POST /portal/admin/ai`,但**後端從來沒有這條 route**
|
||||
// ⇒ 用戶填 Gemini key → 404 → **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
|
||||
// leo 實撞成「重裝後 key 不見」,但真相是「從來沒存進去,所以重填也沒用」。
|
||||
// 產物層鐵證:bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=0。
|
||||
//
|
||||
// 設計約束:
|
||||
// - **不另造第二套儲存**:POST 內部轉呼 credentials.ts 既有的 `storeCredential()`
|
||||
// (唯一寫入路徑=Workers Secret 明文 + D1 目錄列 ref)。
|
||||
// - **永不回傳 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// GET /portal/admin/ai — 回 AI 設定現況(role=admin 閘)。**只回 has_key 布林,永不回 key**。
|
||||
portalRouter.get('/portal/admin/ai', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
|
||||
const tenantSlug = portalTenant(c.env);
|
||||
let hasKey = false;
|
||||
try {
|
||||
const row = await c.env.CREDENTIALS_DB
|
||||
.prepare('SELECT 1 FROM credentials WHERE api_key = ? AND name = ? LIMIT 1')
|
||||
.bind(tenantSlug, 'gemini_api_key')
|
||||
.first();
|
||||
hasKey = !!row;
|
||||
} catch {
|
||||
// D1 未就緒 ⇒ 當作沒設定(不擋頁面),但也不假裝有
|
||||
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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/admin/ai — 存 Gemini key 與/或 Claude 偏好(role=admin 閘)。
|
||||
// body: { gemini_api_key?: string, use_claude_for_extract?: boolean }
|
||||
// 兩者皆選填(前端「留空=不變更」);兩者都沒給 → 400,避免看起來成功但什麼都沒做。
|
||||
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 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);
|
||||
}
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user