Merge branch 'main' into fix/merge-main-into-batch-t173

# Conflicts:
#	console-ui/public/portal/index.html
#	cypher-executor/src/routes/health.ts
#	cypher-executor/src/routes/portal.ts
#	registry/components/kbdb_upsert_block/component.contract.yaml
#	registry/examples/km-wiki-ingest/workflow.yaml
This commit is contained in:
2026-08-02 23:43:16 +08:00
34 changed files with 2039 additions and 33 deletions
+20
View File
@@ -531,6 +531,26 @@ export class GraphExecutor {
iterResults.push(itemResult);
}
// t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤
if (iterResults.length > 0) {
const failures = iterResults.filter(
r => r !== null && typeof r === 'object' && (r as Record<string, unknown>).success === false
);
if (failures.length === iterResults.length) {
const first = failures[0] as Record<string, unknown>;
const errParts: string[] = [];
if (first.error) errParts.push(String(first.error));
if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`);
const bodyData = first.data as { body?: string } | null | undefined;
if (bodyData && typeof bodyData.body === 'string' && bodyData.body) {
errParts.push(bodyData.body.slice(0, 200));
}
throw new Error(
`FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join('') || '未知錯誤'}`
);
}
}
result = { ...(result as Record<string, unknown>), results: iterResults };
break;
}
+17
View File
@@ -37,4 +37,21 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
created_by: 'system',
},
{
// t130rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。
// 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。
// slots 來源:kbdb_list_templates 核實(2026-07-19library-map.test.ts PROD_TRIPLET_SLOTS
// + librarylibrary-map.ts M1 預案:recompute 歸庫用,ensurePortalTemplates 若缺則 PATCH 補入)。
name: 'triplet',
description: 'KBDB 知識圖譜三元組(kbdb-graph-plugin 寫入;portal 讀此 template 建鄰接圖)',
slots: [
'subject', 'predicate', 'object',
'source_block_id', 'confidence', 'clusters_json',
'bridge_score', 'subject_entity_type', 'object_entity_type',
'status', 'superseded_by',
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
'library',
],
created_by: 'system',
},
];
+9 -2
View File
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffergrow 會產生新的 ArrayBuffer
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
} catch {
return 1;
} catch (e) {
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
// 取代只 return 1WASM 寫無資訊的 "HTTP request failed")。
// writeOut 失敗(memory 壞)才 fallback return 1。
const errDetail = e instanceof Error ? e.message : String(e);
const errEnv = new TextEncoder().encode(
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
);
return writeOut(memory.buffer, outPtr, outLenPtr, errEnv);
}
})
: () => 1,
+110 -6
View File
@@ -73,6 +73,32 @@ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; e
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<string, { item: Record<string, unknown>; count: number }>();
for (const s of sources) {
if (!s || typeof s !== 'object') continue;
const item = s as Record<string, unknown>;
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);
@@ -121,6 +147,62 @@ export function filterDeprecatedEntries<T extends { metadata_json?: string | nul
});
}
/**
* 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 triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
if (!body || !Array.isArray(body.records)) return null;
const nodeNames = new Set<string>();
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_idlibrary;回應照 KBDB 原形
//entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
@@ -129,8 +211,9 @@ portalDataRouter.get('/portal/data/search', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const q = c.req.query('q');
if (!q) return c.json({ error: 'q 必填' }, 400);
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) {
@@ -205,6 +288,9 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
// t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
const nodeName = normalizeCjkQuery(c.req.param('name'));
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = portalTenant(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
@@ -214,7 +300,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const result = await executeWebhookGraph(
c.env,
wfGraph,
{ node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
// t116: 補傳 kbdb_baset128: 補傳 templateworkflow 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,
@@ -231,8 +318,23 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
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);
@@ -315,10 +417,12 @@ portalDataRouter.get('/portal/data/chat', (c) =>
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: Array.isArray(inner.sources) ? inner.sources : [],
sources: dedupeSourcesByPage(rawSources),
graph_facts: inner.graph_facts ?? null,
});
}),
+476 -1
View File
@@ -187,6 +187,18 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
return body.record;
}
async function deleteKbdbRecord(env: Bindings, recordId: string): Promise<boolean> {
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
if (res.status === 404) return false;
if (!res.ok) throw new KbdbError(`DELETE /records/${recordId}${res.status}`);
return true;
}
/** KV key for daemon's most-recently-reported active library namest135 daemon hint)。 */
function daemonActiveKey(env: Bindings): string {
return `${portalTenant(env)}:portal:daemon_active_libs`;
}
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
@@ -490,6 +502,7 @@ portalRouter.get('/portal/session', (c) =>
return c.json({
valid: true,
display_name: v.display_name ?? '',
email: v.email ?? '', // t53:完成安裝清單在站內生 daemon config.json 要用(身分顯示欄)
role,
libraries,
graph_allowed: await hasGraphAccess(c.env, libraries),
@@ -701,13 +714,425 @@ function toPublicLibrary(rec: PortalRecord) {
};
}
// POST /portal/daemon/libraries — body {email, password, libraries:[{name, display_name?}]}。
// t52leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」):
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
portalRouter.post('/portal/daemon/libraries', (c) =>
run(c, async () => {
const body = (await c.req.json().catch(() => null)) as
| { email?: string; password?: string; libraries?: { name?: string; display_name?: 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 || (rec.values.status ?? '') !== 'active'
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const wanted = Array.isArray(body?.libraries) ? body!.libraries! : [];
const seeded = await ensurePortalTemplates(c.env);
if (seeded.errors.length > 0) {
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
}
const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
const have = new Set(existing.map((l) => String(l.values.name ?? '')));
const ns = portalNamespace(c.env);
const created: string[] = [];
for (const item of wanted) {
const name = String(item?.name ?? '').trim();
if (!isValidLibraryName(name) || name === '*' || have.has(name)) continue;
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
body: JSON.stringify({
template: LIBRARY_TEMPLATE,
owner_id: ns,
values: {
name,
display_name: String(item?.display_name ?? '').trim() || name,
description: '同步小幫手看守的資料夾',
status: 'active',
},
}),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_library)→ ${res.status}`);
have.add(name);
created.push(name);
}
const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
// t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。
const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean);
if (activeNames.length > 0) {
await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 });
}
return c.json({ success: true, created, libraries: after.map(toPublicLibrary) });
}),
);
// ── 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; }
}
// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)——
// 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。
// 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。
// t122extractor 改讀雲端設定(未設→預設 gemma;gemma+金鑰→一併下發金鑰)。
portalRouter.post('/portal/daemon/config', (c) =>
run(c, async () => {
const body = (await c.req.json().catch(() => null)) as { email?: string; password?: 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: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 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 tenant = portalTenant(c.env);
const extractorCfg = await getExtractorConfig(c.env);
const engine = extractorCfg?.engine ?? 'gemma';
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 天)。
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 });
}),
);
// POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。
// 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。
portalRouter.post('/portal/admin/chat-key', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
const key = String(body?.key ?? '').trim();
if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
const tenant = portalTenant(c.env);
const kvKey = `${tenant}:wf:rag_chat`;
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
let record: Record<string, unknown>;
try {
record = JSON.parse(raw) as Record<string, unknown>;
} catch {
return c.json({ error: 'AI 問答工作流記錄損壞,請重新安裝' }, 500);
}
// 結構不動、只換金鑰值:走遍 graph/config,凡 x-goog-api-key 欄一律設為新值
//(現值可能是 {{credential.gemini_api_key}} 佔位、空字串或舊 key,都直接覆蓋)。
let replaced = 0;
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] = key; replaced += 1; }
else visit(rec[k]);
}
}
};
visit(record['graph']);
visit(record['config']);
if (replaced === 0) return c.json({ error: '工作流裡找不到金鑰欄位,請重新安裝後再試' }, 500);
await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record));
return c.json({ success: true, replaced });
}),
);
// 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,
});
}),
);
// GET /portal/admin/libraries — 庫目錄列表。
// t52leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
// t135:讀 daemon 最近回報的 active libsKV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。
portalRouter.get('/portal/admin/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
return c.json({ success: true, libraries: libs.map(toPublicLibrary), count: libs.length });
// 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint
let daemonActive: Set<string> | null = null;
try {
const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text');
if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim()));
} catch { /* KV 不可達不擋主流程 */ }
const out = libs.map((rec) => {
const lib = toPublicLibrary(rec);
const watching = daemonActive === null ? undefined : daemonActive.has(lib.name);
return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) };
});
const known = new Set(out.map((l) => l.name));
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
try {
const tenant = portalTenant(c.env);
const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
const [autoRes, cardRes, tripletRes] = await Promise.all([
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`).catch(() => null),
]);
// 解析統計,建成 Map 供 O(1) 查找
const cardMap = new Map<string, number>();
if (cardRes?.ok) {
const body = (await cardRes.json()) as { stats?: { library: string; card_count: number }[] };
for (const s of body.stats ?? []) cardMap.set(s.library, s.card_count);
}
const tripletMap = new Map<string, number>();
if (tripletRes?.ok) {
const body = (await tripletRes.json()) as { stats?: { library: string; triplet_count: number }[] };
for (const s of body.stats ?? []) tripletMap.set(s.library, s.triplet_count);
}
// 已登記庫補入統計
for (const lib of out) {
(lib as Record<string, unknown>).card_count = cardMap.get(lib.name) ?? 0;
(lib as Record<string, unknown>).triplet_count = tripletMap.get(lib.name) ?? 0;
}
// 資料面自動出現的庫(蓋章即現身)
if (autoRes?.ok) {
const body = (await autoRes.json()) as { libraries?: string[] };
for (const name of body.libraries ?? []) {
const n = String(name ?? '').trim();
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
if (!n || n === 'general' || known.has(n)) continue;
known.add(n);
const watching = daemonActive === null ? undefined : daemonActive.has(n);
out.push({
record_id: '', name: n, display_name: n,
description: '資料同步時自動出現(可在此補顯示名)',
status: 'active', graph_source: false, auto: true,
card_count: cardMap.get(n) ?? 0,
triplet_count: tripletMap.get(n) ?? 0,
...(watching !== undefined ? { daemon_watching: watching } : {}),
});
}
}
} catch {
// 資料面查不到不擋登記簿(誠實降級:至少顯示已登記的庫)
}
return c.json({ success: true, libraries: out, count: out.length });
}),
);
@@ -874,6 +1299,34 @@ portalRouter.get('/portal/admin/ai', (c) =>
}),
);
// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。
// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。
// ⚠️ 此路由必須在 DELETE /:id 之前宣告(Hono 先到先比;by-name 否則被當成 :id)。
portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const name = decodeURIComponent(c.req.param('name'));
const body = await c.req.json().catch(() => null);
const confirm = String(body?.confirm ?? '').trim();
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
const ownerId = portalTenant(c.env);
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
method: 'PATCH',
body: JSON.stringify({ owner_id: ownerId, library: name }),
});
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
const data = (await res.json()) as { deprecated_count?: number };
return c.json({
success: true,
deprecated_count: data.deprecated_count ?? 0,
message: `已從自動清單移除「${name}」(共標記 ${data.deprecated_count ?? 0} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`,
});
}),
);
// POST /portal/admin/ai — 存 Gemini key 與/或 Claude 偏好(role=admin 閘)。
// body: { gemini_api_key?: string, use_claude_for_extract?: boolean }
// 兩者皆選填(前端「留空=不變更」);兩者都沒給 → 400,避免看起來成功但什麼都沒做。
@@ -921,3 +1374,25 @@ portalRouter.post('/portal/admin/ai', (c) =>
});
}),
);
// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。
// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。
// 資料若有的話,重新同步後會以 auto 庫重新出現。
portalRouter.delete('/portal/admin/libraries/:id', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const recordId = c.req.param('id');
// 成員資格驗(防憑空 id 打到不相干 record)
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
const target = libs.find((l) => l.record_id === recordId);
if (!target) return c.json({ error: '庫不存在' }, 404);
const found = await deleteKbdbRecord(c.env, recordId);
if (!found) return c.json({ error: '庫不存在' }, 404);
return c.json({
success: true,
name: target.values.name ?? '',
message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在,重新同步會再出現。`,
});
}),
);
+3
View File
@@ -103,6 +103,9 @@ export type Bindings = {
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
ARCRUN_BUNDLE_VERSION?: string;
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
// 真相住在 mcp worker 的同名 envmcp/src/types.ts,預設 259200030 天);cypher 這份
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。