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:
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/cypher-executor/node_modules
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -37,4 +37,21 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
|
||||
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
|
||||
created_by: 'system',
|
||||
},
|
||||
{
|
||||
// t130:rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。
|
||||
// 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。
|
||||
// slots 來源:kbdb_list_templates 核實(2026-07-19,library-map.test.ts PROD_TRIPLET_SLOTS)
|
||||
// + library(library-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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||
// await 後重新拿 memory.buffer(grow 會產生新的 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 1(WASM 寫無資訊的 "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,
|
||||
|
||||
@@ -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_id+library;回應照 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 路徑(存在才走;input:node=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_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,
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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 names(t135 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?}]}。
|
||||
// t52(leo 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 /records(portal_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 手法)。
|
||||
// 金鑰不落 log;GET 只回 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 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。
|
||||
// t122:extractor 改讀雲端設定(未設→預設 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 同時設 chat+extractor;has_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_config(daemon/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);
|
||||
|
||||
// 更新 chat(rag_chat workflow)——容忍 404(workflow 未安裝時暫存,安裝後再寫入)
|
||||
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_config(daemon/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_available(t131)。
|
||||
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 — 庫目錄列表。
|
||||
// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
|
||||
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
|
||||
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
|
||||
// t135:讀 daemon 最近回報的 active libs(KV 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 ?? ''}」。資料仍在,重新同步會再出現。`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
||||
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Cypher Executor 端到端測試
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GraphExecutor } from '../src/graph-executor';
|
||||
import type { ComponentRunner, ExecutionGraph } from '../src/types';
|
||||
|
||||
describe('GET /', () => {
|
||||
it('回傳服務狀態', async () => {
|
||||
@@ -191,4 +193,60 @@ describe('POST /execute', () => {
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status code(GraphExecutor 單元測試)
|
||||
describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => {
|
||||
it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => {
|
||||
// mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"}
|
||||
const failLoader = async (_: string): Promise<ComponentRunner> =>
|
||||
async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } });
|
||||
|
||||
const executor = new GraphExecutor(failLoader);
|
||||
|
||||
const graph: ExecutionGraph = {
|
||||
id: 'foreach-fail-t117',
|
||||
name: 'FOREACH 全失敗',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
|
||||
{ id: 'writer', type: 'Component', componentId: 'http_request' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
|
||||
],
|
||||
};
|
||||
|
||||
// t117 核心驗證:全部失敗 → throw(不再靜默)
|
||||
await expect(executor.execute(graph, {})).rejects.toThrow(/401/);
|
||||
});
|
||||
|
||||
it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => {
|
||||
let callCount = 0;
|
||||
// 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑)
|
||||
const mixedLoader = async (_: string): Promise<ComponentRunner> =>
|
||||
async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' };
|
||||
return { success: true, data: { ok: true } };
|
||||
};
|
||||
|
||||
const executor = new GraphExecutor(mixedLoader);
|
||||
|
||||
const graph: ExecutionGraph = {
|
||||
id: 'foreach-mixed-t117',
|
||||
name: 'FOREACH 部分失敗',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
|
||||
{ id: 'writer', type: 'Component', componentId: 'http_request' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
|
||||
],
|
||||
};
|
||||
|
||||
// 部分失敗 → 不拋出,正常回傳 results 陣列
|
||||
const result = await executor.execute(graph, {});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { healthRouter } from '../src/routes/health';
|
||||
import type { Bindings, ExecutionContext } from '../src/types';
|
||||
|
||||
describe('GET /health — bundle_version 欄位', () => {
|
||||
it('無 ARCRUN_BUNDLE_VERSION 時回空字串(老實例情境)', async () => {
|
||||
// wrangler.test.toml 不設此 var → 走 ?? '' fallback
|
||||
const res = await SELF.fetch('http://localhost/health');
|
||||
const data = await res.json() as { ok: boolean; bundle_version: string };
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.bundle_version).toBe('');
|
||||
});
|
||||
|
||||
it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => {
|
||||
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '2026-07-28/6d06162' } as unknown as Bindings;
|
||||
const res = await healthRouter.fetch(
|
||||
new Request('http://localhost/health'),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
const data = await res.json() as { ok: boolean; bundle_version: string };
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.bundle_version).toBe('2026-07-28/6d06162');
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
|
||||
}
|
||||
|
||||
function mockTemplatesExist() {
|
||||
for (const name of ['portal_user', 'portal_library']) {
|
||||
for (const name of ['portal_user', 'portal_library', 'triplet']) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||||
@@ -350,12 +350,262 @@ describe('/portal/admin/libraries', () => {
|
||||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET auto 庫列表過濾 general(general 是系統桶,不在用戶目錄顯示)', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockListByTemplate('portal_library', []);
|
||||
// t142:GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { libraries: ['kb', 'general', 'notes'] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [] });
|
||||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] };
|
||||
const names = data.libraries.map((l) => l.name);
|
||||
expect(names).toContain('kb');
|
||||
expect(names).toContain('notes');
|
||||
expect(names).not.toContain('general');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
|
||||
// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════
|
||||
|
||||
describe('GET /portal/admin/libraries + stats(t142)', () => {
|
||||
it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockListByTemplate('portal_library', [
|
||||
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } },
|
||||
]);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { libraries: ['kb'] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] });
|
||||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] };
|
||||
const kb = data.libraries.find((l) => l.name === 'kb');
|
||||
expect(kb).toBeDefined();
|
||||
expect(kb!.card_count).toBe(42);
|
||||
expect(kb!.triplet_count).toBe(111);
|
||||
});
|
||||
|
||||
it('auto 庫也帶 card_count + triplet_count', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockListByTemplate('portal_library', []);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { libraries: ['notes'] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] });
|
||||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] };
|
||||
const notes = data.libraries.find((l) => l.name === 'notes');
|
||||
expect(notes).toBeDefined();
|
||||
expect(notes!.auto).toBe(true);
|
||||
expect(notes!.card_count).toBe(7);
|
||||
expect(notes!.triplet_count).toBe(108);
|
||||
});
|
||||
|
||||
it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockListByTemplate('portal_library', [
|
||||
{ record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } },
|
||||
]);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { libraries: [] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: [] });
|
||||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] };
|
||||
const empty = data.libraries.find((l) => l.name === 'empty');
|
||||
expect(empty).toBeDefined();
|
||||
expect(empty!.card_count).toBe(0);
|
||||
expect(empty!.triplet_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ t135 庫目錄移除 ═══════════════
|
||||
|
||||
describe('DELETE /portal/admin/libraries(t135)', () => {
|
||||
it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
// 成員驗證:list by template 回有該 record
|
||||
mockListByTemplate('portal_library', [
|
||||
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
|
||||
]);
|
||||
let deleteCalled = false;
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/rec_lib1', method: 'DELETE' })
|
||||
.reply(200, () => { deleteCalled = true; return { success: true }; });
|
||||
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; name: string; message: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.name).toBe('finance');
|
||||
expect(deleteCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('DELETE /:id — 庫不在目錄 → 404', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockListByTemplate('portal_library', []); // 空目錄
|
||||
const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('DELETE /:id — 非 admin → 403', async () => {
|
||||
await seedAdminSession('tok-user', 'rec_u1');
|
||||
mockGetRecord('rec_u1', userValues());
|
||||
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
let deprecateCalled = false;
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
|
||||
.reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
|
||||
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; deprecated_count: number };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.deprecated_count).toBe(12);
|
||||
expect(deprecateCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('DELETE /by-name/:name — 無 confirm → 400', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('DELETE /by-name/:name — 非 admin → 403', async () => {
|
||||
await seedAdminSession('tok-user', 'rec_u1');
|
||||
mockGetRecord('rec_u1', userValues());
|
||||
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
|
||||
|
||||
describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => {
|
||||
const USER_EMAIL = 'daemon@example.com';
|
||||
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算)
|
||||
const USER_RECORD = 'rec_daemon_user';
|
||||
const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; // wrangler.test.toml CONSOLE_TENANT=leo
|
||||
|
||||
/** mock email head lookup(findUserRecordId 走這個路徑)*/
|
||||
function mockEmailLookup(email: string, recordId: string | null) {
|
||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
|
||||
}
|
||||
|
||||
it('未設定 → daemon/config 下發 extractor=gemma,無 gemini_api_key', async () => {
|
||||
// 確保 KV 沒有 extractor config
|
||||
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
|
||||
mockEmailLookup(USER_EMAIL, USER_RECORD);
|
||||
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
|
||||
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; config: Record<string, string> };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.config.extractor).toBe('gemma');
|
||||
expect('gemini_api_key' in data.config).toBe(false);
|
||||
});
|
||||
|
||||
it('設定 gemma+金鑰後 → daemon/config 下發含 gemini_api_key', async () => {
|
||||
await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-test-key-999' }));
|
||||
mockEmailLookup(USER_EMAIL, USER_RECORD);
|
||||
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
|
||||
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; config: Record<string, string> };
|
||||
expect(data.config.extractor).toBe('gemma');
|
||||
expect(data.config.gemini_api_key).toBe('AIza-test-key-999');
|
||||
// cleanup
|
||||
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
|
||||
});
|
||||
|
||||
it('GET /portal/admin/extractor → has_key=true,回應不含金鑰明文', async () => {
|
||||
await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-secret-key' }));
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('GET', '/portal/admin/extractor', undefined, { Authorization: 'Bearer tok-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; engine: string; has_key: boolean };
|
||||
expect(data.engine).toBe('gemma');
|
||||
expect(data.has_key).toBe(true);
|
||||
// 回應主體不含金鑰明文
|
||||
const raw = JSON.stringify(data);
|
||||
expect(raw).not.toContain('AIza-secret-key');
|
||||
expect(raw).not.toContain('gemini_api_key');
|
||||
// cleanup
|
||||
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 7. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
|
||||
|
||||
describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
|
||||
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira', async () => {
|
||||
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => {
|
||||
const res = await SELF.fetch('http://localhost/portal');
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
@@ -367,5 +617,171 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
|
||||
expect(html).not.toContain('/kbdb/');
|
||||
expect(html).not.toContain('X-Arcrun-API-Key');
|
||||
expect(html).not.toContain('Mira');
|
||||
// t97a:bootstrap 後不再預埋 kb 庫
|
||||
expect(html).not.toContain('"name": "kb"');
|
||||
expect(html).not.toContain("name: 'kb'");
|
||||
// t114:無「登記到目錄」按鈕
|
||||
expect(html).not.toContain('lib-adopt');
|
||||
expect(html).not.toContain('登記到目錄');
|
||||
// t131:合併 AI 設定(舊兩區塊已移除)
|
||||
expect(html).toContain('st-ai-panel');
|
||||
expect(html).toContain('st-ai-key');
|
||||
expect(html).toContain('st-ai-use-claude');
|
||||
expect(html).not.toContain('st-extractor-panel');
|
||||
expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 8. t131 合併 AI 設定 ═══════════════
|
||||
|
||||
describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () => {
|
||||
const USER_EMAIL = 'ai-test@example.com';
|
||||
const USER_PW = 'unit-test-pw-1';
|
||||
const USER_RECORD = 'rec_ai_user';
|
||||
const AI_CONFIG_KEY = 'leo:portal:ai_config';
|
||||
const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config';
|
||||
const DAEMON_CAPS_KEY = 'leo:portal:daemon_caps';
|
||||
|
||||
function aiAdminVals(): Record<string, string> {
|
||||
return { email: USER_EMAIL, display_name: 'AI 測試 admin', status: 'active', role: 'admin', password_hash: storedHash };
|
||||
}
|
||||
|
||||
// 與全域 seedAdminSession 相同格式(JSON.stringify({record_id})),fetchMock 由各測試自行 mock
|
||||
async function seedAiSession(token = 'tok-ai-admin', recordId = USER_RECORD) {
|
||||
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||||
}
|
||||
|
||||
function mockAiRecord(recordId = USER_RECORD) {
|
||||
fetchMock.get(KBDB).intercept({ path: `/records/${recordId}`, method: 'GET' }).reply(200, {
|
||||
success: true,
|
||||
record: { record_id: recordId, template_id: 'tpl_pu', values: aiAdminVals() },
|
||||
});
|
||||
}
|
||||
|
||||
function mockEmailLookup(email: string, recordId: string | null) {
|
||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||
fetchMock.get(KBDB).intercept({
|
||||
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
|
||||
method: 'GET',
|
||||
}).reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await env.WEBHOOKS.delete(AI_CONFIG_KEY);
|
||||
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
|
||||
await env.WEBHOOKS.delete(DAEMON_CAPS_KEY);
|
||||
});
|
||||
|
||||
it('POST /ai — 首次設定:同時寫 ai_config+extractor_config+更新 rag_chat workflow', async () => {
|
||||
const ragChatKey = 'leo:wf:rag_chat';
|
||||
const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': '{{credential.gemini}}' } }] }, config: {} };
|
||||
await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/admin/ai',
|
||||
{ gemini_api_key: 'AIza-new-key-123', use_claude_for_extract: false },
|
||||
{ Authorization: 'Bearer tok-ai-admin' }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.has_key).toBe(true);
|
||||
expect(data.use_claude_for_extract).toBe(false);
|
||||
|
||||
const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
|
||||
expect(stored.gemini_api_key).toBe('AIza-new-key-123');
|
||||
expect(stored.use_claude_for_extract).toBe(false);
|
||||
|
||||
const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
|
||||
expect(exCfg.engine).toBe('gemma');
|
||||
expect(exCfg.gemini_api_key).toBe('AIza-new-key-123');
|
||||
|
||||
const updated = JSON.parse((await env.WEBHOOKS.get(ragChatKey, 'text')) ?? '{}') as typeof workflow;
|
||||
expect((updated.graph as { nodes: Array<{ config: Record<string, string> }> }).nodes[0].config['x-goog-api-key']).toBe('AIza-new-key-123');
|
||||
await env.WEBHOOKS.delete(ragChatKey);
|
||||
});
|
||||
|
||||
it('POST /ai — rag_chat 不存在時不報錯(容忍,金鑰存 ai_config 即可)', async () => {
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/admin/ai',
|
||||
{ gemini_api_key: 'AIza-no-workflow-key' },
|
||||
{ Authorization: 'Bearer tok-ai-admin' }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; has_key: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.has_key).toBe(true);
|
||||
const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
|
||||
expect(stored.gemini_api_key).toBe('AIza-no-workflow-key');
|
||||
});
|
||||
|
||||
it('POST /ai — use_claude_for_extract=true:extractor engine=claude,不附 gemini_api_key', async () => {
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/admin/ai',
|
||||
{ gemini_api_key: 'AIza-key-888', use_claude_for_extract: true },
|
||||
{ Authorization: 'Bearer tok-ai-admin' }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; use_claude_for_extract: boolean };
|
||||
expect(data.use_claude_for_extract).toBe(true);
|
||||
const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
|
||||
expect(exCfg.engine).toBe('claude');
|
||||
expect('gemini_api_key' in exCfg).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /ai — 不回明文金鑰;has_key=true;claude_available 依 daemon_caps', async () => {
|
||||
await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-secret-456', use_claude_for_extract: false }));
|
||||
await env.WEBHOOKS.put(DAEMON_CAPS_KEY, JSON.stringify({ has_claude: true }));
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean; claude_available: boolean };
|
||||
expect(data.has_key).toBe(true);
|
||||
expect(data.use_claude_for_extract).toBe(false);
|
||||
expect(data.claude_available).toBe(true);
|
||||
const raw = JSON.stringify(data);
|
||||
expect(raw).not.toContain('AIza-secret-456');
|
||||
expect(raw).not.toContain('gemini_api_key');
|
||||
});
|
||||
|
||||
it('GET /ai — 沒有 daemon_caps → claude_available=false', async () => {
|
||||
await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-key-777' }));
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { claude_available: boolean };
|
||||
expect(data.claude_available).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /portal/daemon/report-capabilities — 有 claude:daemon_caps 寫入 has_claude=true', async () => {
|
||||
mockEmailLookup(USER_EMAIL, USER_RECORD);
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/daemon/report-capabilities', {
|
||||
email: USER_EMAIL, password: USER_PW, has_claude: true, daemon_version: '1.2.0', os: 'darwin',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
const caps = JSON.parse((await env.WEBHOOKS.get(DAEMON_CAPS_KEY, 'text')) ?? '{}');
|
||||
expect(caps.has_claude).toBe(true);
|
||||
expect(caps.daemon_version).toBe('1.2.0');
|
||||
});
|
||||
|
||||
it('舊端點 /portal/admin/chat-key 仍可用(相容)', async () => {
|
||||
const ragChatKey = 'leo:wf:rag_chat';
|
||||
const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': 'old' } }] }, config: {} };
|
||||
await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
|
||||
await seedAiSession();
|
||||
mockAiRecord();
|
||||
const res = await json('POST', '/portal/admin/chat-key', { key: 'AIza-compat-key' }, { Authorization: 'Bearer tok-ai-admin' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; replaced: number };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.replaced).toBeGreaterThan(0);
|
||||
await env.WEBHOOKS.delete(ragChatKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||
@@ -73,7 +74,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
|
||||
}
|
||||
|
||||
function mockTemplatesExist() {
|
||||
for (const name of ['portal_user', 'portal_library']) {
|
||||
for (const name of ['portal_user', 'portal_library', 'triplet']) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||||
@@ -414,3 +415,52 @@ describe('admin 端點 role 閘', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ t130 — triplet template seed ═══════════════
|
||||
|
||||
describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,ensurePortalTemplates 冪等)', () => {
|
||||
it('PORTAL_TEMPLATE_SEEDS 含 triplet 且必要 slots 齊備(pure data)', () => {
|
||||
const seed = PORTAL_TEMPLATE_SEEDS.find((s) => s.name === 'triplet');
|
||||
expect(seed).toBeDefined();
|
||||
for (const slot of ['subject', 'predicate', 'object', 'source_uri', 'status', 'library']) {
|
||||
expect(seed!.slots).toContain(slot);
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /init/seed — triplet 已存 → existing(冪等,不重建)', async () => {
|
||||
for (const name of ['portal_user', 'portal_library', 'triplet']) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||||
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
|
||||
}
|
||||
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
|
||||
expect(data.portal_templates.existing).toContain('triplet');
|
||||
expect(data.portal_templates.created).not.toContain('triplet');
|
||||
});
|
||||
|
||||
it('POST /init/seed — triplet 缺 → 自動補建(新實例首次 seed)', async () => {
|
||||
for (const name of ['portal_user', 'portal_library']) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||||
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
|
||||
}
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/templates/triplet', method: 'GET' })
|
||||
.reply(404, { success: false, error: 'template not found: triplet' });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/templates', method: 'POST' })
|
||||
.reply(200, { success: true, template: { id: 'tpl-triplet-new', name: 'triplet' } });
|
||||
|
||||
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
|
||||
expect(data.portal_templates.created).toContain('triplet');
|
||||
expect(data.portal_templates.existing).not.toContain('triplet');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { workflowsVisible } from '../src/routes/portal';
|
||||
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data';
|
||||
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
@@ -417,3 +417,295 @@ describe('mapGraphWorkflowOutput(#57 workflow 輸出 → plugin 形狀)', ()
|
||||
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════
|
||||
|
||||
describe('normalizeCjkQuery(t95 CJK/ASCII 邊界補空白)', () => {
|
||||
it('純中文 → 不動', () => {
|
||||
expect(normalizeCjkQuery('中文')).toBe('中文');
|
||||
expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複
|
||||
});
|
||||
it('純 ASCII/數字 → 不動', () => {
|
||||
expect(normalizeCjkQuery('ABC123')).toBe('ABC123');
|
||||
expect(normalizeCjkQuery('')).toBe('');
|
||||
});
|
||||
it('CJK→ASCII 邊界插空白', () => {
|
||||
expect(normalizeCjkQuery('協作AI')).toBe('協作 AI');
|
||||
expect(normalizeCjkQuery('中文1234')).toBe('中文 1234');
|
||||
});
|
||||
it('ASCII→CJK 邊界插空白', () => {
|
||||
expect(normalizeCjkQuery('AI協作')).toBe('AI 協作');
|
||||
expect(normalizeCjkQuery('1234中文')).toBe('1234 中文');
|
||||
});
|
||||
it('已有空白不重複插', () => {
|
||||
expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書');
|
||||
});
|
||||
it('全形符號(非 ASCII alnum)不觸發插空白', () => {
|
||||
expect(normalizeCjkQuery('全形:中文')).toBe('全形:中文');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════
|
||||
|
||||
describe('findBestNodeMatch(t96 fuzzy 節點比對)', () => {
|
||||
it('空清單 → null', () => {
|
||||
expect(findBestNodeMatch('AI 協作', [])).toBeNull();
|
||||
});
|
||||
it('完全不包含 → null', () => {
|
||||
expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull();
|
||||
});
|
||||
it('精確子字串命中 → 返回', () => {
|
||||
expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書');
|
||||
});
|
||||
it('多命中 → 取最短(最精確優先)', () => {
|
||||
const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']);
|
||||
expect(result).toBe('AI'); // 最短
|
||||
});
|
||||
it('CJK 未正規化的搜尋詞也能比對(normalizeCjkQuery 先處理)', () => {
|
||||
// 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」
|
||||
expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書');
|
||||
});
|
||||
it('大小寫不敏感', () => {
|
||||
expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════
|
||||
|
||||
describe('GET /portal/data/search(t95 CJK 正規化)', () => {
|
||||
it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => {
|
||||
await seedSession('tok-cn1', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
const cap = captureSearch();
|
||||
await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' });
|
||||
const sent = new URLSearchParams(cap.url().split('?')[1]);
|
||||
expect(sent.get('q')).toBe('AI 協作'); // 已補空白
|
||||
});
|
||||
it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => {
|
||||
await seedSession('tok-cn2', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
const cap = captureSearch();
|
||||
await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' });
|
||||
const sent = new URLSearchParams(cap.url().split('?')[1]);
|
||||
expect(sent.get('q')).toBe('AI 協作'); // 無重複空白
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════
|
||||
|
||||
describe('GET /portal/data/graph/neighbors/:name(t96 fuzzy fallback)', () => {
|
||||
it('plugin 精確命中有鄰居 → 直接回,不觸發 fallback', async () => {
|
||||
await seedSession('tok-gf1', 'rec_a');
|
||||
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
|
||||
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 });
|
||||
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[] };
|
||||
expect(data.neighbors.length).toBe(1); // 有鄰居直接回
|
||||
});
|
||||
|
||||
it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => {
|
||||
await seedSession('tok-gf2', 'rec_a');
|
||||
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
// 精確命中「AI 協作」→ 0 鄰居
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
|
||||
.reply(200, { neighbors: [], edges: [] });
|
||||
// KBDB triplets → 含「AI 協作規範書」
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, {
|
||||
records: [
|
||||
{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } },
|
||||
{ values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } },
|
||||
],
|
||||
});
|
||||
// fallback 以「AI 協作規範書」重查 → 有鄰居
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
|
||||
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
|
||||
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[] };
|
||||
expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居
|
||||
});
|
||||
|
||||
it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => {
|
||||
await seedSession('tok-gf3', 'rec_a');
|
||||
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
|
||||
.reply(200, { neighbors: [], edges: [] });
|
||||
// KBDB triplets → 完全沒有能比對的節點
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] });
|
||||
const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
|
||||
expect(data.neighbors.length).toBe(0); // 誠實回 0,不偽造
|
||||
expect(data.edges.length).toBe(0);
|
||||
});
|
||||
|
||||
it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => {
|
||||
await seedSession('tok-gf4', 'rec_a');
|
||||
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
// plugin 收到的是正規化後的「AI 協作」(%20 分隔)
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
|
||||
.reply(200, { neighbors: [], edges: [] });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] });
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
|
||||
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
|
||||
// 前端傳「AI協作」(無空白,URL encoded)
|
||||
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[] };
|
||||
expect(data.neighbors.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 12. t116: graph_neighbors workflow 補傳 kbdb_base ═══════════════
|
||||
|
||||
describe('GET /portal/data/graph/neighbors/:name(t116 kbdb_base 補傳)', () => {
|
||||
it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_base,workflow 正常執行不崩', async () => {
|
||||
// 設定 session(["*"] 全庫,放行 graph 粗閘)
|
||||
await seedSession('tok-t116', 'rec_t116');
|
||||
mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
|
||||
// 在 WEBHOOKS KV 放 graph_neighbors workflow(Input→Output 直通)
|
||||
// 這個 workflow 不用 {{input.kbdb_base}},只驗工作流路徑正常執行(不走 graphBase fallback)
|
||||
// 若沒補傳 kbdb_base 但 workflow 內有 {{input.kbdb_base}} 的節點,URL 解析失敗 → executeWebhookGraph 回 error
|
||||
// 此測試退而求其次:用無外部依賴的直通圖確認整個路徑都通(workflow 取代 plugin fallback)
|
||||
const wfKey = `${TENANT}:wf:graph_neighbors`;
|
||||
await env.WEBHOOKS.put(wfKey, JSON.stringify({
|
||||
graph: {
|
||||
id: 'gn-t116',
|
||||
name: 'graph_neighbors',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input' },
|
||||
// comp_passthrough 是內建零件,不需外部 fetch,直接回傳 context
|
||||
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
|
||||
{ id: 'output', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'pass', type: 'PIPE' },
|
||||
{ from: 'pass', to: 'output', type: 'PIPE' },
|
||||
],
|
||||
},
|
||||
description: 't116 test',
|
||||
created_at: '2026-07-29T00:00:00.000Z',
|
||||
}));
|
||||
|
||||
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t116' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[]; count: number; kbdb_base?: string };
|
||||
// workflow 走 comp_passthrough,output = 整個 context(含 kbdb_base)
|
||||
// mapGraphWorkflowOutput 只取 neighbors/edges,其他欄位不影響回應
|
||||
expect(Array.isArray(data.neighbors)).toBe(true);
|
||||
expect(Array.isArray(data.edges)).toBe(true);
|
||||
// 確認不是 502(graph_neighbors workflow 執行失敗)
|
||||
expect(res.status).not.toBe(502);
|
||||
|
||||
await env.WEBHOOKS.delete(wfKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 13. t128: graph_neighbors workflow 補傳 template ═══════════════
|
||||
|
||||
describe('GET /portal/data/graph/neighbors/:name(t128 template 補傳)', () => {
|
||||
it('tenant 有 graph_neighbors workflow → portal 傳入 template=triplet,workflow 不崩', async () => {
|
||||
await seedSession('tok-t128', 'rec_t128');
|
||||
mockGetRecord('rec_t128', userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
|
||||
const wfKey = `${TENANT}:wf:graph_neighbors`;
|
||||
await env.WEBHOOKS.put(wfKey, JSON.stringify({
|
||||
graph: {
|
||||
id: 'gn-t128',
|
||||
name: 'graph_neighbors',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input' },
|
||||
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
|
||||
{ id: 'output', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'pass', type: 'PIPE' },
|
||||
{ from: 'pass', to: 'output', type: 'PIPE' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t128' });
|
||||
// template 有進 context → workflow 執行不崩(非 502)
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
|
||||
expect(Array.isArray(data.neighbors)).toBe(true);
|
||||
|
||||
await env.WEBHOOKS.delete(wfKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 14. t129: dedupeSourcesByPage 純函式 ═══════════════
|
||||
|
||||
describe('dedupeSourcesByPage(t129 出處去重)', () => {
|
||||
it('同 page_name 合併,hit_count 標計數', () => {
|
||||
const srcs = [
|
||||
{ page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
|
||||
{ page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
|
||||
{ page_name: '企業版功能', mode: 'keyword', source: 'gitea://docs/enterprise.md' },
|
||||
];
|
||||
const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
|
||||
expect(out.length).toBe(1); // 3 筆→1 筆
|
||||
expect(out[0].page_name).toBe('企業版功能');
|
||||
expect(out[0].hit_count).toBe(3);
|
||||
});
|
||||
|
||||
it('不同 page_name 各保留一筆;單筆無 hit_count', () => {
|
||||
const srcs = [
|
||||
{ page_name: 'A 頁', mode: 'semantic' },
|
||||
{ page_name: 'B 頁', mode: 'keyword' },
|
||||
];
|
||||
const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
|
||||
expect(out.length).toBe(2);
|
||||
expect(out.every(s => s.hit_count === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('page 欄(備用)也能去重', () => {
|
||||
const srcs = [
|
||||
{ page: '備用頁', mode: 'semantic' },
|
||||
{ page: '備用頁', mode: 'keyword' },
|
||||
];
|
||||
const out = dedupeSourcesByPage(srcs) as { page?: string; hit_count?: number }[];
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].hit_count).toBe(2);
|
||||
});
|
||||
|
||||
it('空陣列 → 空陣列;非物件條目跳過', () => {
|
||||
expect(dedupeSourcesByPage([])).toEqual([]);
|
||||
const out = dedupeSourcesByPage([null, 'oops', { page_name: 'X' }]);
|
||||
expect(out.length).toBe(1);
|
||||
});
|
||||
|
||||
it('page_name 優先於 page', () => {
|
||||
const srcs = [
|
||||
{ page_name: '優先頁', page: '備用頁' },
|
||||
{ page_name: '優先頁', page: '備用頁' },
|
||||
];
|
||||
const out = dedupeSourcesByPage(srcs) as { hit_count?: number }[];
|
||||
expect(out.length).toBe(1); // 同 page_name → 合為一筆
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user