diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 6c41341..cbdb768 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -164,7 +164,23 @@ .kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; } - + @@ -355,6 +371,15 @@
Gemini API Key
聊天問答與文件萃取都用這一把。免費申請,金鑰只存在你自己的知識庫裡。
+ +
@@ -796,6 +821,34 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { // t131 AI 設定(合併 Gemini API Key+Claude 加強版) (function () { + /** + * 切換金鑰欄的「已存 / 可輸入」兩態(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(); } + }); + // 進入設定頁時讀取現有設定(GET /portal/admin/ai) function loadAiConfig() { if (!(S.profile && S.profile.role === 'admin')) return; @@ -803,8 +856,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .then(function (r) { return r.ok ? safeJson(r) : null; }) .then(function (d) { if (!d) return; - var ki = $('st-ai-key'); - if (ki && d.has_key) ki.placeholder = '已設定(留空=不變更)'; + // leo 2026-08-01 規格:已存 → 唯讀「已輸入」+「修改」鈕;未存 → 一般輸入框 + setAiKeySaved(!!d.has_key); var cb = $('st-ai-use-claude'); if (cb) { // 有 claude 才能勾;沒有則停用並顯示提示 @@ -850,7 +903,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { sb.disabled = false; if (guard401(x.status)) return; if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; } - if ($('st-ai-key')) { $('st-ai-key').value = ''; $('st-ai-key').placeholder = '已設定(留空=不變更)'; } + // 存好了才切回「已輸入」唯讀態。只有真的送了新 key 才切; + // 單純改 Claude 勾選(k 為空)時維持現狀,不誤報「已輸入」。 + if (k) setAiKeySaved(true); var claudeOn = x.d && x.d.use_claude_for_extract; m.textContent = claudeOn ? '已儲存,萃取改用 Claude Code。小幫手請重連一次生效。' diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 364b86c..fda4d65 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -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, + }); + }), +);