補上合併漏收的修改檔(t189/t181/CIS portal 本體)

上一筆 5d78806 我只 add 了新增檔(favicon 三件套),四個修改檔沒收進去
⇒ 合併內容只推了一半,t189 的 tenant 比對修復其實還躺在工作區。

本次補上(實測與已刪分支 HEAD 23f2fd4 逐檔相同):
· cypher-executor/src/routes/portal.ts      +81(t181 /portal/daemon/extract、t189 tenant)
· cypher-executor/src/routes/portal-data.ts +21
· console-ui/public/portal/index.html      +169(CIS 視覺、版本卡)
· cypher-executor/tests/portal-admin.test.ts +45

教訓:merge --no-commit 後要 git add -A,不能只 add status 裡看到的第一類。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-05 13:31:57 +08:00
parent 5d78806806
commit 6c5706ba7d
4 changed files with 284 additions and 32 deletions
+81
View File
@@ -718,6 +718,87 @@ function toPublicLibrary(rec: PortalRecord) {
// t52leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」):
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。
// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。
//
// 🔴 t181leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」):
// 舊路徑要用戶自己去 Google 申請 Gemini API Key,實測撞到三種災難:
// ① 完全不知道要去哪裡設定(台大資工碩士都卡住 ⇒ leo:「一般人就完蛋了」)
// ② 拿到的金鑰所屬 Google 帳號被 flag ⇒ 403 PERMISSION_DENIED、換專案也無效
// ③ 52 檔全滅還要把金鑰傳給別人實打才查得出真因
// ⇒ 走 Workers AI`env.AI` binding**完全不需要任何金鑰**,
// 用的是用戶自己 CF 帳號內建的 AI;他的 Google 帳號被封也不受影響。
//
// 為什麼萃取放雲端而不是 daemon 直接打:daemon 端**沒有 AI binding**
//binding 是 Worker 專屬),且模型選型集中在雲端才能統一換。
// ⚠️ 隱私邊界不變:daemon 送的是**已在本機轉成文字的原稿**,回傳的是知識卡;
// 原始檔案(docx/pdf)仍然不出用戶的電腦。
// 🔑 認證用 `X-Arcrun-API-Key`(=namespace),**不是帳密**
// daemon 的密碼**只在連線精靈當下用過就丟、不落地**(config.json 沒有密碼欄,刻意的安全設計),
// 但背景萃取是每輪自動跑的 ⇒ 根本拿不到密碼。
// 而 daemon 送卡片上雲時本來就帶這個 headercollector/direct.go:355),沿用同一把最自然。
portalRouter.post('/portal/daemon/extract', (c) =>
run(c, async () => {
// 🔴 t189leo 08-04 實撞:geek6688 萃取回 401「X-Arcrun-API-Key 不正確」):
//
// t181 那一輪我改成 `apiKey !== portalTenant(c.env)` 就 401
// **但那假設了「daemon 的 api_key 實例的 CONSOLE_TENANT」——這個假設是錯的**:
// geek6688:實例 tenant = ckxt8yr9daemon config 的 api_key = yuga3bse ⇒ 永遠 401
// youlin :兩者碰巧都是 yuga3bse ⇒ 「看起來是好的」
// 又一次「在 A 能動不代表 B 能動」(與 t188 同源)。
// ⚠️ 當時我還寫了「key 錯就 401」的測試,**把錯誤假設固化成綠燈**——
// 測試只證明「符合我的假設」,不證明「假設是對的」。
//
// 正解=照本 repo 既有慣例:這把 key 是**租戶識別**,不是要比對的共用密語
// (見 `webhooks-named.ts` 的 `owner_id: apiKey` 用法)。
// 本端點只用實例自己的 `env.AI` 生成、**不寫任何資料**、不回傳庫內內容
// ⇒ 有帶 key 即可,不做等值比對。
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const body = (await c.req.json().catch(() => null)) as
| { page_name?: string; text?: string }
| null;
const pageName = String(body?.page_name ?? '').trim();
const srcText = String(body?.text ?? '');
if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400);
if (!c.env.AI) {
// 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠)
return c.json({ error: '這個部署沒有綁定 Workers AIwrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
}
// 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
// 兩邊要一起改;daemon 端在 collector/extract_gemma.go。
// 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。
const REL = '>'.repeat(2);
const prompt =
`把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` +
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
try {
// 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140
// llama-4-scout 2373ms/答案最完整;對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿)。
const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
messages: [{ role: 'user', content: prompt }],
max_tokens: 2048,
temperature: 0.2,
} as never)) as { response?: string } | undefined;
const card = String(out?.response ?? '').trim();
if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
// 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard
const marker = `# ${pageName}`;
const idx = card.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' });
} catch (e) {
return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502);
}
}),
);
portalRouter.post('/portal/daemon/libraries', (c) =>
run(c, async () => {
const body = (await c.req.json().catch(() => null)) as