/** * RAG Portal 查詢面 — P3:/portal/data/* server-side enforce(portal-auth design §3.3/§3.4/§5, * Gitea #24/#25)。本檔是本 SDD 的**安全核心**。 * * 安全模型(design §3.3,與 console 的關鍵差異): * - console 登入後把 CONSOLE_TENANT 下發給前端直打 /kbdb/*;**portal 前端絕不持有租戶字串**, * 只有 portal session token。portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/search, * 所以 enforce 全在 server:session → 回讀 user record(唯一真相源)→ 取 libraries → * server 注入 owner_id+library 後轉發 KBDB。 * - caller 自帶的 owner_id / library query 參數**一律忽略**(不是拒絕——拒絕會變成 * 「試參數名」的 oracle;直接靜默覆蓋,怎麼傳都是自己的權限範圍)。 * - ["*"]=全庫:只注 owner_id、不注 library(design §3.3)。 * * 不洩存在性(紅線):越庫的 entry(含根本不存在的 id、別的租戶的 id)一律回**同一句 404**, * 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外=graph 粗閘按 SDD 明定回 403(D-4)。 * * 薄殼(rule 07):本檔沒有新能力——搜尋/取條目能力真身在 KBDB base(P1 的 library filter), * graph 真身在 kbdb-graph-plugin,工作流真身在 WEBHOOKS/ANALYTICS KV(與 /webhooks/named、 * /workflows/:name/executions 同一資料源)。這裡只做「權限注入+轉發/讀取」。 * * log 紅線:本檔不 log 任何 token / 密碼 / 查詢內容。 */ import { Hono } from 'hono'; import type { Context } from 'hono'; import type { Bindings } from '../types'; import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal'; // Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import // portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。 import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant'; import { graphBase, graphHeaders } from './kbdb-proxy'; import { executeWebhookGraph } from '../actions/webhook-handlers'; export const portalDataRouter = new Hono<{ Bindings: Bindings }>(); // ── tenant workflow in-process 執行(portal-demo-suite)─────────────────────── // // 為什麼:RAG self-hosted 實例不部署 kbdb-graph-plugin worker(graphBase 直連會 1042, // Arcrun#57);graph/chat 這類查詢能力在該實例是以 tenant 的 named workflow 形式存在 //(WEBHOOKS KV `{tenant}:wf:{name}`,與 /webhooks/named 同一資料源)。 // 怎麼接:直接 import executeWebhookGraph(webhooks-named.ts trigger/query 端點同一個 // 執行入口)在 **本 worker 進程內** 執行——絕不 fetch 自己的 hostname //(global_fetch_strictly_public 下打自己=self-loop,見 /portal/data/workflows 同款註解)。 /** 讀 tenant 的 named workflow graph(`{tenant}:wf:{name}`)。不存在/壞 record → null。 */ async function getTenantWorkflowGraph(env: Bindings, name: string): Promise | null> { // #108:workflow 是 CLI `acr push` 用實例 namespace 寫進來的(`{ns}:wf:*`), // 所以讀的時候也要用同一個 namespace,不是帳號層那個字串。 const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text'); if (!raw) return null; try { const rec = JSON.parse(raw) as { graph?: Record }; return rec.graph && typeof rec.graph === 'object' ? rec.graph : null; } catch { return null; // 壞 record 視同不存在(呼叫端各自誠實回報) } } /** workflow 最終節點輸出常見兩形:本體即結果,或再包一層 data(http_request 類零件慣例)。取有目標欄位的那層。 */ function unwrapWorkflowData(data: unknown, key: string): Record { const outer = data && typeof data === 'object' ? (data as Record) : {}; if (key in outer) return outer; const inner = outer.data; if (inner && typeof inner === 'object' && key in (inner as Record)) { return inner as Record; } return outer; } /** * graph_neighbors workflow 輸出 → 與 kbdb-graph-plugin 相同的回應形狀 {neighbors, edges, count} *(前端 doGraphSearch 讀 neighbors/edges;count=neighbors 數,重算不信 workflow 自報)。 * 純函式(單測用 export)。 */ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; edges: unknown[]; count: number } { const layer = unwrapWorkflowData(data, 'neighbors'); const neighbors = Array.isArray(layer.neighbors) ? layer.neighbors : []; const edges = Array.isArray(layer.edges) ? layer.edges : []; return { neighbors, edges, count: neighbors.length }; } /** * 出處清單按 page_name 去重(t129): * rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。 * 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。 * page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。 * 純函式,單測用 export。 */ export function dedupeSourcesByPage(sources: unknown[]): unknown[] { const seen = new Map; count: number }>(); for (const s of sources) { if (!s || typeof s !== 'object') continue; const item = s as Record; const page = typeof item.page_name === 'string' ? item.page_name : typeof item.page === 'string' ? item.page : ''; const existing = seen.get(page); if (existing) { existing.count += 1; } else { seen.set(page, { item, count: 1 }); } } return [...seen.values()].map(({ item, count }) => count > 1 ? { ...item, hit_count: count } : item, ); } /** 越庫/不存在 一律同一句 404(不洩存在性)。 */ function notFound(c: Context<{ Bindings: Bindings }>): Response { return c.json({ error: '找不到這筆資料' }, 404); } /** entry 的庫歸屬:metadata_json.$.library,未標記 → 'general'(design §3.2 fallback,與 KBDB P1 同語意)。 */ export function entryLibrary(entry: { metadata_json?: string | null }): string { try { const meta = JSON.parse(entry.metadata_json ?? 'null') as { library?: unknown } | null; if (meta && typeof meta.library === 'string' && meta.library.trim()) return meta.library; } catch { /* metadata 壞掉 → 視同未標記 */ } return 'general'; } /** 用戶庫集合是否覆蓋某庫(["*"]=全庫)。 */ function canReadLibrary(userLibraries: string[], library: string): boolean { return userLibraries.includes('*') || userLibraries.includes(library); } /** * 搜尋殘影過濾(**Arcrun#46 上游修好前的 portal 端治標**——舊管線的 deprecated 產物還躺在 * KBDB 裡污染搜尋結果;根治=上游清資料/重建索引,那修好後這段可整段拔掉)。 * 濾掉:metadata_json.status === 'deprecated' 的 entry、content 以「(舊管線產物」開頭的 entry、 * 內部型別 entry(value=slot 值外漏的無標題雜項列、workflow=工作流定義——都是系統內部件, * 不是給搜尋用戶看的內容;2026-07-18 leo 客戶測試回饋「搜尋結果偶見無標題雜項列」)。 * metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。 * 純函式(單測用 export)。 */ // execution_log/execution_log_usage(KV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部 // 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時 // 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。 const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']); export function filterDeprecatedEntries( entries: T[], ): T[] { return entries.filter((e) => { if (e.entry_type && INTERNAL_ENTRY_TYPES.has(e.entry_type)) return false; try { const meta = JSON.parse(e.metadata_json ?? 'null') as { status?: unknown } | null; if (meta && meta.status === 'deprecated') return false; } catch { /* parse 失敗 → 保留 */ } if (String(e.content ?? '').startsWith('(舊管線產物')) return false; return true; }); } /** * CJK/ASCII 邊界插空白正規化(t95): * 「AI協作」→「AI 協作」;「協作AI」→「協作 AI」;已有空白不重複插。 * 只動查詢端,不動索引端。純函式,單測用 export。 */ export function normalizeCjkQuery(q: string): string { // U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat. const isCjk = (c: string) => /[぀-鿿豈-﫿]/.test(c); const isAsciiAlnum = (c: string) => /[぀-鿿豈-﫿]/.test(c); let result = ''; for (let i = 0; i < q.length; i++) { const ch = q[i]; if (result.length > 0) { const prev = result[result.length - 1]; if (prev !== ' ' && ch !== ' ' && ((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) { result += ' '; } } result += ch; } return result; } /** * 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用): * 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。 */ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null { const term = normalizeCjkQuery(searchTerm).toLowerCase(); if (!term) return null; const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term)); if (hits.length === 0) return null; return hits.reduce((a, b) => a.length <= b.length ? a : b); } /** * 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端 * `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。 */ async function tripletCount(env: Bindings, owner: TenantId | null): Promise { try { // owner=null = 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個 // 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。 const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`); if (!res.ok) return null; const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null; if (!body || !Array.isArray(body.stats)) return null; let total = 0; for (const row of body.stats) { if (typeof row?.triplet_count !== 'number') return null; total += row.triplet_count; } return total; } catch { return null; } } /** * 三元組普查(Arcrun#100)——回答總圖那句「知識庫還沒有任何關聯」到底能不能講。 * * leo 的原則(已寫在 portal.ts §②.5 daemon diagnostics):**「不要讓『查不到』和『沒有』 * 長得一樣」**。t161 前科:手補的 record owner_id 存成 None ⇒ 全量查得到、按 owner_id 過濾 * 的畫面永遠空——比真的沒資料更難查。所以本租戶數為 0 時**再花一次查詢換一條路徑** * (同一支端點但不帶 owner),問「這個庫到底有沒有三元組」: * owned>0 → 有資料 * owned=0 且 any=0 → 真的空(此時、也只有此時,畫面才准印 0) * owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到 * owned=null → 讀不到 → 畫面說讀不到 */ async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> { const owned = await tripletCount(env, tenant); if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次 return { owned, any: await tripletCount(env, null) }; } /** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */ async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise { try { const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`); if (!res.ok) return null; const body = (await res.json().catch(() => null)) as { records?: { values?: Record }[] } | null; if (!body || !Array.isArray(body.records)) return null; const nodeNames = new Set(); for (const r of body.records) { const v = r?.values; if (!v || typeof v !== 'object') continue; if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim()); if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim()); } return findBestNodeMatch(searchTerm, [...nodeNames]); } catch { return null; // fallback 失敗靜默略過,原本 0 結果直接回 } } // GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic //(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形 //(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳—— // semantic 未開的降級行為沿 KBDB 既有,P1 已保 library 照 enforce)。 portalDataRouter.get('/portal/data/search', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const qRaw = c.req.query('q'); if (!qRaw) return c.json({ error: 'q 必填' }, 400); const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端) const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) { // 帳號沒被授權任何庫:誠實空結果(不打 KBDB——沒有可查範圍就沒有查詢) return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' }); } const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) }); if (!libraries.includes('*')) params.set('library', libraries.join(',')); // 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死, // caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。 if (c.req.query('mode') === 'semantic') { params.set('mode', 'semantic'); // 🔴 t183(leo 08-04 實撞:「語義搜尋搜到一大堆不相關的內容」 // ——搜「火星座標」卻跑出 n8n 版本比較表、Leo 填答): // Vectorize 會**硬湊滿 topK 筆**,湊不到就把低分的塞進來 ⇒ 尾巴全是無關內容。 // kbdb 早就支援 min_score(`kbdb/src/embed.ts:225`,issue #67), // 但 portal **從來沒傳** ⇒ 等同沒有閾值,低分尾全端到用戶面前。 // // 0.75 怎麼來的(**實測分數分布,不是猜的**;youlin 實例搜「火星座標 奧林帕斯山」): // 0.908 / 0.881 / 0.881 / 0.880 / 0.870 / 0.815 / 0.798 / 0.787 ← 全是火星座標,真相關 // ─────────────────────── 斷崖 ─────────────────────── // 0.742 姨媽說故事 / 0.740 ax-academy / 0.739×8 n8n 版本比較表 ← 全是雜訊 // 斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75:相關的全留、雜訊全砍。 // // 允許前端覆寫(想放寬看更多可傳 min_score),但**不接受 0/負數** // ——那等於關掉閾值,正是 t183 要修的病本身。 // // 🔴 2026-08-05 修正(leo 實撞「語義搜尋 0 命中」):**這裡不再硬寫預設值**。 // 上面 0.75 是照**舊模型 bge-base-en-v1.5** 的分數分布定的;08-05 換 bge-m3 後 // 分數尺度整體下移,0.75 砍掉的變成正解 ⇒ 新上傳的檔一律 0 命中。 // 根因=**閾值是模型的性質,卻被複製到呼叫端**,換模型時沒人想到要回來改這行。 // ⇒ 預設值移到 `kbdb/src/embed.ts` 的 `DEFAULT_MIN_SCORE`(緊鄰 DEFAULT_EMBED_MODEL), // portal 只在**使用者顯式指定**時才傳。**不要把數字搬回來。** const msRaw = Number(c.req.query('min_score')); if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw)); } const entryType = c.req.query('entry_type'); if (entryType) params.set('entry_type', entryType); const limit = c.req.query('limit'); if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit); const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`); if (!res.ok) { // 錯誤回應照原樣透傳(誠實,不加工) return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } // Arcrun#46 治標:server-side 濾掉舊管線 deprecated 殘影再回(count 重算)。 // 上游修好(清資料/重建索引)後,這段連同 filterDeprecatedEntries 一起拔掉。 const body = (await res.json().catch(() => null)) as | { entries?: { metadata_json?: string | null; content?: string | null }[]; count?: number } | null; if (!body || !Array.isArray(body.entries)) { // 回應不是預期形狀 → 照原樣回(不因治標把正常錯誤形狀吃掉) return c.json(body ?? { error: 'KBDB 回應不是 JSON' }, body ? 200 : 502); } const entries = filterDeprecatedEntries(body.entries); return c.json({ ...body, entries, count: entries.length }); }), ); // GET /portal/data/entries/:id — 卡片詳頁。**逐筆驗庫**(design §5): // ① entry 必須屬於本實例租戶(owner_id=CONSOLE_TENANT)——防拿別租戶 id 直讀; // ② entry 的 library(NULL→general)必須在用戶庫集合內——防拿越庫 id 直讀。 // 兩者不符與不存在同回 404(不洩存在性)。 portalDataRouter.get('/portal/data/entries/:id', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) return notFound(c); const res = await kbdbFetch(c.env, `/entries/${encodeURIComponent(c.req.param('id'))}`); if (res.status === 404) return notFound(c); if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } }; const entry = body.entry; if (!entry) return notFound(c); if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c); if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c); return c.json({ success: true, entry }); }), ); // GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘): // 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。 // 放行後的資料源二選一(Arcrun#57): // ① tenant 有 `{tenant}:wf:graph_neighbors` workflow → in-process 執行它(RAG self-hosted // 實例不部署 kbdb-graph-plugin,直連會 1042),輸出映射成與 plugin 相同形狀; // ② 沒有 → fallback 原本 graphBase 直連 plugin(Mira/leo21c 實例相容,行為一字不變)。 portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (!(await hasGraphAccess(c.env, libraries))) { return c.json({ error: '無知識圖譜檢視權限' }, 403); } // t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點) const nodeName = normalizeCjkQuery(c.req.param('name')); // ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant) const tenant = knowledgeOwner(c.env); const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors'); if (wfGraph) { const depthRaw = c.req.query('depth') ?? ''; const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2; const result = await executeWebhookGraph( c.env, wfGraph, // t116: 補傳 kbdb_base;t128: 補傳 template(workflow fetch_triplets.url 用 {{input.template}}) { node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' }, 'graph_neighbors', tenant, c.executionCtx, ); if (!result.success) { // workflow 執行失敗 → 誠實 502(不假裝無關聯) return c.json({ error: `graph_neighbors workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502); } return c.json(mapGraphWorkflowOutput(result.data)); } // ② plugin fallback(Mira/leo21c 相容) const base = graphBase(c.env); const headers = graphHeaders(c.env); try { const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers }); if (!res.ok) { return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } // t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」) const resText = await res.text().catch(() => ''); let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null; try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ } if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 && Array.isArray(data.edges) && data.edges.length === 0) { const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName); if (fallbackName && fallbackName !== nodeName) { const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers }); return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } }); } } return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } catch (e) { // plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯) return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502); } }), ); // GET /portal/data/graph/overview — 書庫總圖(Arcrun#39 藏書地圖的 GUI 資料切片): // 全租戶 active 三元組 → {nodes:[{name,degree}], edges:[{subject,predicate,object}]}。 // 權限=與 neighbors 同一道 D-4 graph 粗閘;資料直讀 KBDB records(與 search 同 kbdbFetch 路徑, // 不經 graph plugin、不撞 1042)。邊數上限 500(demo 量級遠低於此;超限誠實截斷並回 truncated)。 portalDataRouter.get('/portal/data/graph/overview', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (!(await hasGraphAccess(c.env, libraries))) { return c.json({ error: '無知識圖譜檢視權限' }, 403); } const tenant = knowledgeOwner(c.env); const [res, census] = await Promise.all([ kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`), tripletCensus(c.env, tenant), ]); const tripletsTotal = census.owned; if (!res.ok) { return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } const body = (await res.json().catch(() => null)) as | { records?: { values?: Record }[] } | null; // #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖, // 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。 if (!body || !Array.isArray(body.records)) { return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502); } const records = body.records; const EDGE_CAP = 500; const seen = new Set(); const edges: { subject: string; predicate: string; object: string }[] = []; const degree = new Map(); let truncated = false; for (const r of records) { const v = r && typeof r.values === 'object' && r.values ? r.values : null; if (!v) continue; if (v.status === 'deprecated') continue; const s = typeof v.subject === 'string' ? v.subject.trim() : ''; const o = typeof v.object === 'string' ? v.object.trim() : ''; if (!s || !o) continue; const p = typeof v.predicate === 'string' ? v.predicate : ''; const key = `${s}${p}${o}`; if (seen.has(key)) continue; seen.add(key); if (edges.length >= EDGE_CAP) { truncated = true; break; } edges.push({ subject: s, predicate: p, object: o }); degree.set(s, (degree.get(s) ?? 0) + 1); degree.set(o, (degree.get(o) ?? 0) + 1); } const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d })); // #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)—— // confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」 // scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科) // unreadable :連條數都讀不到 → 只能說讀不到 let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null; if (nodes.length === 0) { if (census.owned === null) emptyReason = 'unreadable'; else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊 else if (census.any === null) emptyReason = 'unreadable'; else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty'; } return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, // 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部 truncated: truncated || records.length >= 500, triplets_total: tripletsTotal, empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty', empty_reason: emptyReason, }); }), ); // GET /portal/data/chat?question=... — AI 問答(portal-demo-suite)。 // 設計哲學:AI 檢索=用戶手動搜尋同一套——同 search 的 requirePortalUser 閘、同一個租戶資料面, // 只是把「人下關鍵字」換成「workflow 代查再作答」;前端不因走 AI 多拿任何權限。 // 機制同 graph_neighbors:in-process 執行 tenant 的 rag_chat workflow(executeWebhookGraph, // 絕不 fetch 自己 hostname);workflow 不存在 → 誠實 404,不假裝這實例有問答能力。 portalDataRouter.get('/portal/data/chat', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const question = c.req.query('question'); if (!question) return c.json({ error: 'question 必填' }, 400); const wfGraph = await getTenantWorkflowGraph(c.env, 'rag_chat'); if (!wfGraph) return c.json({ error: '此實例未安裝問答 workflow' }, 404); const result = await executeWebhookGraph( c.env, wfGraph, { question }, 'rag_chat', knowledgeOwner(c.env), c.executionCtx, ); if (!result.success) { // workflow 執行失敗 → 誠實 502(不把錯誤編成答案) return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502); } // 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造) // t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。 const inner = unwrapWorkflowData(result.data, 'answer'); const rawSources = Array.isArray(inner.sources) ? inner.sources : []; return c.json({ answer: typeof inner.answer === 'string' ? inner.answer : '', sources: dedupeSourcesByPage(rawSources), graph_facts: inner.graph_facts ?? null, }); }), ); // ── 上傳(portal-demo-suite)──────────────────────────────────────────────── /** * 上傳檔名驗證(純函式,單測用 export): * - 去路徑分隔(/ \)只取最後一段(擋 ../ 穿越)、去控制字元; * - 空名/以 . 開頭(隱藏檔/./..)→ 無效(null); * - 強制 .md 結尾(.txt 改副檔名、其餘直接補 .md——上傳面收的是知識文件,一律當 markdown 收件); * - 最終長度限 100(含副檔名),超過 → 無效。 */ export function sanitizeUploadFilename(raw: unknown): string | null { if (typeof raw !== 'string') return null; let name = (raw.split(/[/\\]/).pop() ?? '').trim(); // eslint-disable-next-line no-control-regex name = name.replace(/[\u0000-\u001f\u007f]/g, ''); if (!name || name.startsWith('.')) return null; if (/\.txt$/i.test(name)) name = name.replace(/\.txt$/i, '.md'); if (!/\.md$/i.test(name)) name = `${name}.md`; if (name.length > 100) return null; return name; } // base64 內容上限(收 .md/.txt 知識文件,2 MiB 原文 ≈ 2.7 MB base64 已綽綽有餘;防拿上傳面塞大檔) const MAX_UPLOAD_B64_CHARS = 3 * 1024 * 1024; // POST /portal/data/upload — body {filename, content_b64}(portal-demo-suite)。 // requirePortalUser 閘;server-side POST Gitea contents API 寫進 PORTAL_UPLOAD_REPO 的 // docs/{filename}——token 只在 server 側,前端永遠拿不到(同 kbdb-proxy token 慣例)。 // 未設 upload bindings(三者任一缺)→ 404「未啟用上傳」(Mira 零影響:功能不存在)。 portalDataRouter.post('/portal/data/upload', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; if (!uploadEnabled(c.env)) return c.json({ error: '此實例未啟用上傳' }, 404); const body = await c.req.json().catch(() => null) as { filename?: unknown; content_b64?: unknown } | null; const filename = sanitizeUploadFilename(body?.filename); if (!filename) return c.json({ error: 'filename 無效(不可含路徑、不可空、長度限 100)' }, 400); const contentB64 = body?.content_b64; if (typeof contentB64 !== 'string' || !contentB64) return c.json({ error: 'content_b64 必填' }, 400); if (contentB64.length > MAX_UPLOAD_B64_CHARS) return c.json({ error: '檔案過大(上限約 2 MB)' }, 413); const base = (c.env.PORTAL_UPLOAD_GITEA ?? '').replace(/\/$/, ''); const repo = c.env.PORTAL_UPLOAD_REPO ?? ''; let res: Response; try { res = await fetch(`${base}/api/v1/repos/${repo}/contents/docs/${encodeURIComponent(filename)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `token ${c.env.PORTAL_UPLOAD_TOKEN}`, }, body: JSON.stringify({ content: contentB64, // commit 訊息帶上傳者(display_name 非機密),收件溯源用;不進任何內容 message: `portal 上傳:docs/${filename}(${auth.user.values.display_name ?? 'portal user'})`, }), }); } catch (e) { return c.json({ error: `知識庫收件服務不可達:${e instanceof Error ? e.message : String(e)}` }, 502); } if (res.status === 409 || res.status === 422) { // Gitea 同 path 已存在(版本差異回 409 或 422)→ 統一誠實回 409 return c.json({ error: '同名文件已存在,請改檔名後重傳' }, 409); } if (!res.ok) { return c.json({ error: `知識庫收件失敗(HTTP ${res.status})` }, 502); } return c.json({ success: true, filename, path: `docs/${filename}` }); }), ); // GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger** //(trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。 // 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403)/ all / off(整頁不存在 → 404)。 portalDataRouter.get('/portal/data/workflows', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const setting = (c.env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase(); if (setting === 'off') return notFound(c); if (!workflowsVisible(c.env, auth.user.values.role ?? 'user')) { return c.json({ error: '需要 admin 權限' }, 403); } // 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。 // 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop), // 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。 const tenant = knowledgeOwner(c.env); const prefix = `${tenant}:wf:`; const list = await c.env.WEBHOOKS.list({ prefix }); const workflows = await Promise.all( list.keys.map(async (k) => { const name = k.name.slice(prefix.length); const raw = await c.env.WEBHOOKS.get(k.name, 'text'); let description = ''; let created_at = ''; let cron_expr: string | undefined; if (raw) { try { const rec = JSON.parse(raw) as { description?: string; created_at?: string; cron_expr?: string }; description = rec.description ?? ''; created_at = rec.created_at ?? ''; cron_expr = rec.cron_expr; } catch { /* 壞 record 誠實留空 */ } } // 最近一次執行:KV 額度事故修復(2026-08-07)改打 KBDB GET /execution-log/latest // (原走 ANALYTICS_KV stats:{name}:* list,免費層 list 也是 1,000/日)。KBDB= // API-as-Wall:不直連 D1,走既有 kbdbFetch(本檔已在用,見上方 import)。 let last_execution: { timestamp: string; verdict?: string } | null = null; const execRes = await kbdbFetch( c.env, `/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`, ); const execBody = await execRes.json().catch(() => null) as { success?: boolean; execution?: { verdict: string; recorded_at: number } | null; } | null; if (execRes.ok && execBody?.success && execBody.execution) { last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict }; } return { name, description, created_at, cron_expr, last_execution }; }), ); return c.json({ success: true, workflows, total: workflows.length, read_only: true }); }), ); // ═══════════════════════════════════════════════════════════════════════════ // 授權的 AI(arcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限 // ═══════════════════════════════════════════════════════════════════════════ // // leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西; // AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」 // 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。 // // 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`), // 於是查詢時只好去找一把**服務內部金鑰**(KBDB_INTERNAL_TOKEN)直打 KBDB—— // 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。 // // 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支: // ① 一律 requirePortalUser(session → 回讀 user record → 停用即時生效), // ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線: // 呼叫端自己帶租戶字串=繞過庫過濾), // ③ 越權與不存在同回 404(不洩存在性)。 // // 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤, // 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。 /** * record 的庫歸屬。與 entry 不同:**沒有 `library` slot 的 record 不套庫過濾**。 * * 為什麼不比照 entry 用 'general' fallback:entry 是知識內容(庫是它的第一屬性,沒標就歸 * general 是對的);record 是結構化資料列(contact / workflow_metadata / triplet…), * 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback, * 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺,不是隔離。 * 租戶邊界仍然守著(owner_id 由 server 注入/逐筆比對),這裡只多守「有標庫的別越庫」。 */ function recordLibrary(values: Record | undefined): string | null { const lib = values?.library; return typeof lib === 'string' && lib.trim() ? lib.trim() : null; } /** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */ function canReadRecord( rec: { values?: Record; owner_id?: string | null }, tenant: TenantId, libraries: string[], ): boolean { if (!isOwnedBy(rec.owner_id, tenant)) return false; const lib = recordLibrary(rec.values); return lib === null || canReadLibrary(libraries, lib); } // GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。 // KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。 // // 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜** // (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制): // no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題 // filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離 // confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」 // scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上** // (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾) // scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。 // // ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。 // 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。 portalDataRouter.get('/portal/data/map', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) { return c.json({ success: true, libraries: [], count: 0, empty_confirmed: true, empty_reason: 'no_library_grant', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。', }); } const tenant = knowledgeOwner(c.env); const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`); if (!res.ok) { return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null; if (!body || !Array.isArray(body.libraries)) { return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502); } const allowed = body.libraries.filter( (l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library), ); if (allowed.length > 0) { return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null }); } // 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。 if (body.libraries.length > 0) { // 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。 return c.json({ success: true, libraries: [], count: 0, empty_confirmed: true, empty_reason: 'filtered_out', note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。', }); } const census = await tripletCensus(c.env, tenant); if (census.owned === null || (census.owned === 0 && census.any === null)) { return c.json({ success: true, libraries: [], count: 0, empty_confirmed: false, empty_reason: 'unreadable', note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。', }); } if (census.owned === 0 && (census.any ?? 0) > 0) { return c.json({ success: true, libraries: [], count: 0, empty_confirmed: false, empty_reason: 'scope_mismatch', instance_triplet_count: census.any, note: `讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` + '這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' + '請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。', }); } return c.json({ success: true, libraries: [], count: 0, empty_confirmed: true, empty_reason: 'confirmed_empty', note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。', }); }), ); // GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。 portalDataRouter.get('/portal/data/map/:library', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); const library = c.req.param('library'); if (!canReadLibrary(libraries, library)) return notFound(c); const res = await kbdbFetch( c.env, `/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`, ); if (res.status === 404) return notFound(c); if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } }); }), ); // GET /portal/data/templates — template 清單。 // template=虛擬表定義(schema),**全域共享不分租戶**(kbdb-proxy 同一裁定,leo 2026-06-14): // 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。 portalDataRouter.get('/portal/data/templates', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const res = await kbdbFetch(c.env, '/templates'); if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } }); }), ); // POST /portal/data/templates — 建 template(name + slots)。 // 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。 // created_by 記租戶(溯源),template 本身全域可見可用。 portalDataRouter.post('/portal/data/templates', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const body = (await c.req.json().catch(() => null)) as | { name?: unknown; slots?: unknown; description?: unknown } | null; if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) { return c.json({ error: 'name 與 slots[] 必填' }, 400); } const res = await kbdbFetch(c.env, '/templates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: body.name, slots: body.slots, description: typeof body.description === 'string' ? body.description : undefined, created_by: knowledgeOwner(c.env), }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }), ); // GET /portal/data/records/by-template/:template — 某 template 底下的 record。 // server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。 portalDataRouter.get('/portal/data/records/by-template/:template', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 }); const tenant = knowledgeOwner(c.env); const res = await kbdbFetch( c.env, `/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`, ); if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); const body = (await res.json().catch(() => null)) as | { records?: { values?: Record; owner_id?: string | null }[] } | null; if (!body || !Array.isArray(body.records)) { return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502); } // KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。 const records = body.records.filter((r) => canReadRecord(r, tenant, libraries)); return c.json({ success: true, records, count: records.length }); }), ); // GET /portal/data/records/:recordId — 單筆 record。 // 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。 portalDataRouter.get('/portal/data/records/:recordId', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) return notFound(c); const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`); if (res.status === 404) return notFound(c); if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); const body = (await res.json().catch(() => null)) as | { record?: { values?: Record; owner_id?: string | null } } | null; const record = body?.record; if (!record) return notFound(c); if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c); return c.json({ success: true, record }); }), ); // POST /portal/data/records — 依 template 填一筆 record。 // owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬, // 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。 portalDataRouter.post('/portal/data/records', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) { return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403); } const body = (await c.req.json().catch(() => null)) as | { template?: unknown; values?: unknown } | null; if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') { return c.json({ error: 'template 與 values 必填' }, 400); } const values = body.values as Record; const targetLib = recordLibrary(values); if (targetLib !== null && !canReadLibrary(libraries, targetLib)) { // 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則: // 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。 return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403); } const res = await kbdbFetch(c.env, '/records', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }), ); // GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令): // // 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」 // // 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。 // // 🔴 t213(2026-08-08,InkStoneCo 總管交辦):leo 實測拿真檔驗四個真實問題,只答得出一題 // (雲端這半的 bundle_version)——其餘三題(本機檔案總量、失敗分類統計、daemon 版本/ // 自我更新狀態)需要本機資料,雲端這支端點天生構不到(封測者的瀏覽器與他電腦上的 // daemon 是兩個獨立行程)。核准方案:本機那半改由 arcrun-app(daemon 桌面殼)匯出時 // 直接讀本機檔案,並改打**新增的** `GET /portal/daemon/diagnostics`(X-Arcrun-API-Key // 認證,免帳密)取雲端這半,兩者合併成一份完整診斷檔——arcrun-app 那半見 // products/arcrun-rag repo t213 phase 2。本端點(portal 網頁版)保留當退路(daemon // 完全掛掉時仍按得到),文案需誠實講清楚自己只有一半,完整診斷請去 daemon 匯出 // (portal 前端文案改動不在本次 matrix/arcrun 範圍內,由 arcrun-rag 那邊處理)。 // // 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變,核心邏輯已抽成 buildDiagnostics() // (portal.ts)——與新的 daemon 版共用同一份查詢邏輯(薄殼原則)。 portalDataRouter.get('/portal/data/diagnostics', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const tenant = knowledgeOwner(c.env); const core = await buildDiagnostics(c.env, tenant); return c.json({ generated_at: new Date().toISOString(), instance_url: new URL(c.req.url).origin, bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null, ...core, }); }), );