Compare commits
7 Commits
e36cd2d990
...
0860e84d22
| Author | SHA1 | Date | |
|---|---|---|---|
| 0860e84d22 | |||
| 6d4980d3d7 | |||
| ccb86481ae | |||
| eb9f2db513 | |||
| 429b2d965f | |||
| f6728974ea | |||
| 2ff36962be |
@@ -356,6 +356,29 @@
|
||||
<div id="st-key-status" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- t122 萃取引擎(daemon 萃取知識卡用的 AI;admin-level,同 chat-key;server 再強制閘) -->
|
||||
<div class="panel" id="st-extractor-panel">
|
||||
<div style="font-size:17px;font-weight:600">萃取引擎</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">同步小幫手把你的文件變成知識卡時用的 AI。選好後請讓小幫手重連一次。</div>
|
||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;gap:16px;align-items:center">
|
||||
<label style="display:flex;gap:6px;align-items:center;cursor:pointer;font-size:14px">
|
||||
<input type="radio" name="st-extractor-engine" id="st-engine-gemma" value="gemma" checked>
|
||||
<span>Gemini(推薦,免費)</span>
|
||||
</label>
|
||||
<label style="display:flex;gap:6px;align-items:center;cursor:pointer;font-size:14px">
|
||||
<input type="radio" name="st-extractor-engine" id="st-engine-claude" value="claude">
|
||||
<span>Claude Code(本機已裝才選)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="st-extractor-key-row" style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="font-size:13px;color:rgba(var(--ink-rgb),.55)">到 <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">aistudio.google.com/apikey</a> 免費取得金鑰</div>
|
||||
<input type="password" id="st-extractor-key" class="txt" placeholder="貼上 Gemini API 金鑰(留空=不變更)" autocomplete="off">
|
||||
</div>
|
||||
<button class="btn" id="st-extractor-save">儲存萃取引擎設定</button>
|
||||
<div id="st-extractor-status" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -793,6 +816,70 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
});
|
||||
})();
|
||||
|
||||
// t122 萃取引擎設定(engine 切換顯示隱藏金鑰輸入框 + 存檔)
|
||||
(function () {
|
||||
// 引擎切換:Gemini 顯示金鑰欄;Claude Code 隱藏
|
||||
function updateExtractorKeyRowVisibility() {
|
||||
var row = $('st-extractor-key-row');
|
||||
if (!row) return;
|
||||
var el = document.getElementById('st-engine-gemma');
|
||||
row.style.display = (el && el.checked) ? '' : 'none';
|
||||
}
|
||||
['st-engine-gemma', 'st-engine-claude'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', updateExtractorKeyRowVisibility);
|
||||
});
|
||||
updateExtractorKeyRowVisibility();
|
||||
|
||||
// 進入設定頁時讀取現有設定(GET /portal/admin/extractor)
|
||||
function loadExtractorConfig() {
|
||||
if (!(S.profile && S.profile.role === 'admin')) return;
|
||||
fetch(API_BASE + '/portal/admin/extractor', { headers: authHeaders() })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
var gemmaEl = document.getElementById('st-engine-gemma');
|
||||
var claudeEl = document.getElementById('st-engine-claude');
|
||||
if (d.engine === 'claude' && claudeEl) claudeEl.checked = true;
|
||||
else if (gemmaEl) gemmaEl.checked = true;
|
||||
updateExtractorKeyRowVisibility();
|
||||
if (d.has_key) {
|
||||
var ki = $('st-extractor-key');
|
||||
if (ki) ki.placeholder = '已設定(留空=不變更)';
|
||||
}
|
||||
})
|
||||
.catch(function () { /* 讀不到不擋頁面 */ });
|
||||
}
|
||||
|
||||
// 存檔
|
||||
var sb = $('st-extractor-save');
|
||||
if (sb) sb.addEventListener('click', function () {
|
||||
var m = $('st-extractor-status');
|
||||
var engineEl = document.querySelector('input[name="st-extractor-engine"]:checked');
|
||||
var engine = engineEl ? engineEl.value : 'gemma';
|
||||
var key = (($('st-extractor-key') && $('st-extractor-key').value) || '').trim();
|
||||
var body = { engine: engine };
|
||||
if (engine === 'gemma' && key) body.gemini_api_key = key;
|
||||
sb.disabled = true; m.textContent = '儲存中…'; m.style.color = '';
|
||||
fetch(API_BASE + '/portal/admin/extractor', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
sb.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; }
|
||||
if ($('st-extractor-key')) { $('st-extractor-key').value = ''; $('st-extractor-key').placeholder = '已設定(留空=不變更)'; }
|
||||
m.textContent = '已儲存。小幫手請點「連上知識庫」重連一次即可生效'; m.style.color = '#3f7a4f';
|
||||
})
|
||||
.catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; });
|
||||
});
|
||||
|
||||
// loadExtractorConfig 掛到全域(供 loadSettings 呼叫)
|
||||
window._loadExtractorConfig = loadExtractorConfig;
|
||||
})();
|
||||
|
||||
$('st-logout').addEventListener('click', function () {
|
||||
fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
|
||||
dropSession();
|
||||
@@ -943,8 +1030,6 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
.then(function (b) {
|
||||
// 409=已 bootstrap 過(冪等視為就緒);其餘失敗誠實丟出
|
||||
if (!b.ok && b.status !== 409) throw new Error(b.d.error || '初始化沒有成功,請再試一次');
|
||||
// 預設庫 kb 自動登記(leo:庫要自動出現,不是叫用戶去登記)——
|
||||
// 同步小幫手預設就寫進 kb 庫,這裡把登記簿先蓋好章;已存在(409/重複)不吵。
|
||||
return post('/portal/login', { email: email, password: pw });
|
||||
});
|
||||
})
|
||||
@@ -952,12 +1037,6 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
if (!l.ok || !l.d.session_token) throw new Error(l.d.error || '帳號建好了,但自動登入沒成功——請用剛設定的帳密登入');
|
||||
S.token = l.d.session_token;
|
||||
try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* noop */ }
|
||||
// 預設庫登記(用 portal session;失敗不擋進入)
|
||||
return fetch(API_BASE + '/portal/admin/libraries', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify({ name: 'kb', display_name: '知識庫', description: '同步小幫手預設寫入的庫' })
|
||||
}).catch(function () { /* 已存在或無權限都不擋 */ });
|
||||
})
|
||||
.then(function () {
|
||||
$('fs-submit').disabled = false;
|
||||
@@ -1476,29 +1555,24 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
}
|
||||
|
||||
function renderAdminLibs() {
|
||||
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">還沒有登記任何庫。未登記時整個系統視同單一 general 庫。</div>'; return; }
|
||||
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。</div>'; return; }
|
||||
$('ad-libs').innerHTML = adminLibs.map(function (l) {
|
||||
var disabled = l.status === 'disabled';
|
||||
// t52:auto 庫=資料蓋章自動出現、還沒登記進登記簿(沒有 record_id)——
|
||||
// 那兩顆按鈕會帶空 id 打 API=按了就錯,改給「登記到目錄」一顆(leo 判準:不放死按鈕)。
|
||||
if (l.auto) {
|
||||
return '<div class="card-item">' +
|
||||
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||||
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
|
||||
'<span class="dim mono" style="font-size:13px">' + esc(l.name) + '</span>' +
|
||||
'<span class="tag green">啟用中</span><span class="tag">同步自動出現</span></div>' +
|
||||
'<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">'
|
||||
+ '這個庫來自同步小幫手的資料夾,已經可以搜尋與設權限。登記到目錄後可以改顯示名。</div>' +
|
||||
'<div class="btnrow"><button class="btn2" data-act="lib-adopt" data-name="' + esc(l.name) + '">登記到目錄</button></div>' +
|
||||
'</div>';
|
||||
}
|
||||
var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中
|
||||
var removeBtn = l.auto
|
||||
? '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove-auto" data-name="' + esc(l.name) + '">移除</button>'
|
||||
: '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove" data-id="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">移除</button>';
|
||||
return '<div class="card-item">' +
|
||||
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||||
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
|
||||
'<span class="dim mono" style="font-size:13px">' + esc(l.name) + '</span>' +
|
||||
'<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>' +
|
||||
(l.auto
|
||||
? '<span class="tag green">啟用中</span><span class="tag">同步自動出現</span>'
|
||||
: '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') +
|
||||
removeBtn +
|
||||
'</div>' +
|
||||
(l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
|
||||
(!l.auto && l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
|
||||
(notWatching ? '<div style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)">小幫手目前沒有在同步這個資料夾</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -1614,18 +1688,39 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||||
return;
|
||||
}
|
||||
// t52:把「自動出現的庫」正式登記進目錄(之後就能改顯示名)
|
||||
if (act === 'lib-adopt') {
|
||||
var lname = t.getAttribute('data-name') || '';
|
||||
if (!lname) return;
|
||||
// 移除已登記庫(有 record_id)
|
||||
if (act === 'lib-remove') {
|
||||
var libId = t.getAttribute('data-id') || '';
|
||||
var libName = t.getAttribute('data-name') || libId;
|
||||
if (!confirm('把「' + libName + '」從目錄移除?\n\n資料不會刪除,重新同步時會再出現。')) return;
|
||||
t.disabled = true;
|
||||
adminApi('POST', '/portal/admin/libraries', { name: lname, display_name: lname, description: '同步小幫手看守的資料夾' })
|
||||
adminApi('DELETE', '/portal/admin/libraries/' + encodeURIComponent(libId))
|
||||
.then(function (x) {
|
||||
t.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok && x.status !== 409) { toast(x.d.error || ('登記失敗(HTTP ' + x.status + ')')); return; }
|
||||
toast('已登記到目錄');
|
||||
loadAdmin();
|
||||
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
|
||||
adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; });
|
||||
renderAdminLibs();
|
||||
toast('已從目錄移除');
|
||||
})
|
||||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||||
return;
|
||||
}
|
||||
// 移除 auto 庫(無 record_id,entries 標 deprecated)
|
||||
if (act === 'lib-remove-auto') {
|
||||
var autoName = t.getAttribute('data-name') || '';
|
||||
var input = window.prompt('移除「' + autoName + '」會讓它的內容不再被搜尋到(資料保留可還原)。\n\n請輸入庫名確認:');
|
||||
if (input === null) return; // 取消
|
||||
if (input.trim() !== autoName) { toast('庫名輸入不符,取消移除'); return; }
|
||||
t.disabled = true;
|
||||
adminApi('DELETE', '/portal/admin/libraries/by-name/' + encodeURIComponent(autoName), { confirm: autoName })
|
||||
.then(function (x) {
|
||||
t.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
|
||||
adminLibs = adminLibs.filter(function (l) { return l.name !== autoName; });
|
||||
renderAdminLibs();
|
||||
toast('已移除(' + (x.d.deprecated_count || 0) + ' 筆標為不可搜)');
|
||||
})
|
||||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||||
return;
|
||||
@@ -1640,6 +1735,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
function loadSettings() {
|
||||
var p = S.profile;
|
||||
if (!p) { $('st-me').innerHTML = '<div class="muted">載入中…</div>'; return; }
|
||||
if (window._loadExtractorConfig) window._loadExtractorConfig();
|
||||
var libs = p.libraries || [];
|
||||
var libHtml = libs.indexOf('*') >= 0
|
||||
? '<span class="tag green">全部知識庫</span>'
|
||||
|
||||
@@ -500,6 +500,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,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Bindings } from '../types';
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
healthRouter.get('/health', (c) =>
|
||||
c.json({ ok: true })
|
||||
c.json({ ok: true, bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? '' })
|
||||
);
|
||||
|
||||
healthRouter.get('/', (c) =>
|
||||
|
||||
@@ -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);
|
||||
@@ -274,7 +300,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
const result = await executeWebhookGraph(
|
||||
c.env,
|
||||
wfGraph,
|
||||
{ node: nodeName, 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,
|
||||
@@ -390,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,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -184,6 +184,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)}`);
|
||||
@@ -751,14 +763,41 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
|
||||
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;
|
||||
@@ -781,17 +820,21 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const tenant = portalTenant(c.env);
|
||||
return c.json({
|
||||
success: true,
|
||||
config: {
|
||||
cypher_url: new URL(c.req.url).origin,
|
||||
namespace: tenant,
|
||||
library: 'kb',
|
||||
extractor: 'claude',
|
||||
email,
|
||||
instance_name: String(rec.values.display_name ?? ''),
|
||||
},
|
||||
});
|
||||
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 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -837,16 +880,65 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}(t122)。
|
||||
// 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)。
|
||||
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);
|
||||
const out = libs.map(toPublicLibrary);
|
||||
// 讀 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));
|
||||
// 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library)
|
||||
try {
|
||||
@@ -855,9 +947,16 @@ portalRouter.get('/portal/admin/libraries', (c) =>
|
||||
const body = (await res.json()) as { libraries?: string[] };
|
||||
for (const name of body.libraries ?? []) {
|
||||
const n = String(name ?? '').trim();
|
||||
if (!n || known.has(n)) continue;
|
||||
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
|
||||
if (!n || n === 'general' || known.has(n)) continue;
|
||||
known.add(n);
|
||||
out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true });
|
||||
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,
|
||||
...(watching !== undefined ? { daemon_watching: watching } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -940,3 +1039,53 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
|
||||
return c.json({ success: true, library: toPublicLibrary(updated) });
|
||||
}),
|
||||
);
|
||||
|
||||
// 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} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// 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 ?? ''}」。資料仍在,重新同步會再出現。`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -96,6 +96,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' })
|
||||
@@ -376,12 +376,169 @@ 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', []);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||||
.reply(200, { libraries: ['kb', 'general', 'notes'] });
|
||||
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 頁後紅線不回退)═══════════════
|
||||
// ═══════════════ 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();
|
||||
@@ -393,5 +550,11 @@ 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('登記到目錄');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, normalizeCjkQuery, findBestNodeMatch } 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';
|
||||
@@ -578,3 +578,134 @@ describe('GET /portal/data/graph/neighbors/:name(t96 fuzzy fallback)', () =>
|
||||
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 → 合為一筆
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,27 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。
|
||||
* 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。
|
||||
* 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。
|
||||
*/
|
||||
export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`UPDATE entries
|
||||
SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'),
|
||||
updated_at = unixepoch()
|
||||
WHERE owner_id = ?
|
||||
AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ?
|
||||
AND (json_extract(metadata_json, '$.status') IS NULL
|
||||
OR json_extract(metadata_json, '$.status') != 'deprecated')`,
|
||||
)
|
||||
.bind(ownerId, library)
|
||||
.run();
|
||||
return (result.meta?.changes as number | undefined) ?? 0;
|
||||
}
|
||||
|
||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||
|
||||
@@ -209,3 +209,18 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
||||
}
|
||||
|
||||
/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */
|
||||
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
|
||||
const evRes = await db
|
||||
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
|
||||
.bind(recordId)
|
||||
.all<{ entry_id: string }>();
|
||||
const rows = evRes.results ?? [];
|
||||
if (rows.length === 0) return false;
|
||||
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
|
||||
for (const { entry_id } of rows) {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,27 @@ import { mapRoutes } from './routes/map';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// t115 global auth guard(三修=總管手改,fail-closed 到底).
|
||||
// 為什麼不留讀取的寬容窗口:leo 07-28 實證的洞就是「知道網址即可讀走全部知識」——
|
||||
// 讀取放行等於洞沒補。老實例的升級路徑是「重跑安裝器」(會同時注入 token 與新 workflow),
|
||||
// 那條路本來就存在(t103 連動提示會叫用戶更新),不需要以繼續外洩為代價換相容。
|
||||
// Health(/ 與 /health)永遠豁免:daemon 的雲端版本偵測與監控要打得到。
|
||||
app.use('*', async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (path === '/' || path === '/health') return next();
|
||||
const token = c.env.KBDB_INTERNAL_TOKEN;
|
||||
if (!token) {
|
||||
// 沒有 token=這個實例還沒封口。一律拒絕(含讀取),並在訊息裡告訴維運怎麼修。
|
||||
console.warn('[kbdb] KBDB_INTERNAL_TOKEN 未設定——全部請求拒絕,請重跑安裝器以注入金鑰');
|
||||
return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401);
|
||||
}
|
||||
const auth = c.req.header('Authorization');
|
||||
if (!auth || auth !== `Bearer ${token}`) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
|
||||
app.get('/health', (c) => c.json({ ok: true }));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import {
|
||||
createEntry,
|
||||
deprecateEntriesByLibrary,
|
||||
getEntry,
|
||||
listEntries,
|
||||
updateEntry,
|
||||
@@ -139,6 +140,18 @@ entryRoutes.get('/:id', async (c) => {
|
||||
return c.json({ success: true, entry });
|
||||
});
|
||||
|
||||
// PATCH /entries/deprecate-by-library — body {owner_id, library}。
|
||||
// t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。
|
||||
// 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。
|
||||
entryRoutes.patch('/deprecate-by-library', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null;
|
||||
const ownerId = String(body?.owner_id ?? '').trim();
|
||||
const library = String(body?.library ?? '').trim();
|
||||
if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400);
|
||||
const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library);
|
||||
return c.json({ success: true, deprecated_count: count });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
entryRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Records route — structured records (entry_values composed by a template).
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { createRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
|
||||
import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
|
||||
|
||||
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -47,3 +47,10 @@ recordRoutes.patch('/:recordId', async (c) => {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /records/:recordId — 刪除一筆 record 及其底層 entries。
|
||||
recordRoutes.delete('/:recordId', async (c) => {
|
||||
const found = await deleteRecord(c.env.DB, c.req.param('recordId'));
|
||||
if (!found) return c.json({ success: false, error: 'not found' }, 404);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
export type Bindings = {
|
||||
DB: D1Database;
|
||||
ENVIRONMENT: string;
|
||||
// Auth guard (t115 二修, fail-closed): provisioned by the installer automatically.
|
||||
// NOT set → writes (POST/PATCH/DELETE/PUT) rejected 401; reads pass with a warning
|
||||
// (upgrade-window grace so read-only workflows don't break before both workers are
|
||||
// updated together).
|
||||
// SET → all non-health routes require `Authorization: Bearer <token>`.
|
||||
// cypher-executor sends this via kbdbBase(); portal/webhooks/recipes send it inline.
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
// Optional embed module (issue #7 / SDD T2.4). Present ONLY when the self-host opened
|
||||
// semantic search (kbdb_embed:true → deploy injects [[vectorize]] + [ai]). Base never
|
||||
// requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// t115 二修 — kbdb auth guard tests (fail-closed behaviour).
|
||||
//
|
||||
// Token NOT set:
|
||||
// - GET / and GET /health → 200 (health exempt)
|
||||
// - GET /entries → 200 + console.warn (reads pass during upgrade window)
|
||||
// - POST/PATCH/DELETE /entries → 401 (fail-closed for writes)
|
||||
//
|
||||
// Token SET:
|
||||
// - / and /health → 200 (health always exempt)
|
||||
// - missing / wrong / no-Bearer prefix → 401
|
||||
// - correct Bearer → 200
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
// ⚠️ 這裡曾經「複製一份 index.ts 的 middleware」來測——複本會與真實作漂移,
|
||||
// 測綠了也不代表線上安全(總管 07-28 三修時發現:真 app 已改 fail-closed,複本還放行讀取)。
|
||||
// 現在改成:把真 middleware 從 src/index.ts 匯入無法做到(app 已組裝好路由),
|
||||
// 故改為「複本必須與 src/index.ts 的行為斷言一致」+一條結構測試(見最下方 test)。
|
||||
function makeApp(token?: string) {
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (path === '/' || path === '/health') return next();
|
||||
const envToken = c.env.KBDB_INTERNAL_TOKEN;
|
||||
if (!envToken) return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401);
|
||||
const auth = c.req.header('Authorization');
|
||||
if (!auth || auth !== `Bearer ${envToken}`) return c.json({ error: 'Unauthorized' }, 401);
|
||||
return next();
|
||||
});
|
||||
|
||||
app.get('/', (c) => c.json({ status: 'ok' }));
|
||||
app.get('/health', (c) => c.json({ ok: true }));
|
||||
app.get('/entries', (c) => c.json({ success: true, entries: [] }));
|
||||
app.post('/entries', async (c) => c.json({ success: true }));
|
||||
app.patch('/entries/:id', async (c) => c.json({ success: true }));
|
||||
app.delete('/entries/:id', async (c) => c.json({ success: true }));
|
||||
|
||||
// Bind the token into the env for every request.
|
||||
const original = app.fetch.bind(app);
|
||||
return (req: Request) =>
|
||||
original(req, { DB: {} as D1Database, ENVIRONMENT: 'test', KBDB_INTERNAL_TOKEN: token } as Bindings, {});
|
||||
}
|
||||
|
||||
describe('kbdb auth guard — token NOT set', () => {
|
||||
const fetch = makeApp(undefined);
|
||||
|
||||
it('GET / passes (health exempt)', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/'));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /health passes (health exempt)', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/health'));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /entries 也被拒(fail-closed:讀取放行=洞沒補,t115 三修)', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/entries'));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /entries without token → 401 (fail-closed for writes)', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/entries', { method: 'POST' }));
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('PATCH /entries/x without token → 401 (fail-closed for writes)', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/entries/x', { method: 'PATCH' }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('DELETE /entries/x without token → 401 (fail-closed for writes)', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/entries/x', { method: 'DELETE' }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kbdb auth guard — token SET', () => {
|
||||
const SECRET = 'test-secret-abc123';
|
||||
const fetch = makeApp(SECRET);
|
||||
|
||||
it('GET / always passes (health exempt)', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/'));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /health always passes (health exempt)', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/health'));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /entries without Authorization → 401', async () => {
|
||||
const res = await fetch(new Request('http://kbdb/entries'));
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('GET /entries with wrong token → 401', async () => {
|
||||
const res = await fetch(
|
||||
new Request('http://kbdb/entries', {
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /entries with Bearer prefix missing → 401', async () => {
|
||||
const res = await fetch(
|
||||
new Request('http://kbdb/entries', {
|
||||
headers: { Authorization: SECRET },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /entries with correct Bearer token → 200', async () => {
|
||||
const res = await fetch(
|
||||
new Request('http://kbdb/entries', {
|
||||
headers: { Authorization: `Bearer ${SECRET}` },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('POST /entries with correct Bearer token → 200', async () => {
|
||||
const res = await fetch(
|
||||
new Request('http://kbdb/entries', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${SECRET}` },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('POST /entries without token → 401', async () => {
|
||||
const res = await fetch(
|
||||
new Request('http://kbdb/entries', { method: 'POST' }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// 結構閘(總管 07-28 加):src/index.ts 的 guard 必須是 fail-closed——
|
||||
// 無 token 時不得有任何「return next()」的放行分支(health 豁免除外)。
|
||||
// 這條擋的是「測試複本與真實作漂移」那類假綠。
|
||||
import { readFileSync } from 'node:fs';
|
||||
describe('t115 結構閘:真實作必須 fail-closed', () => {
|
||||
it('src/index.ts 無 token 分支不放行', () => {
|
||||
const src = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8');
|
||||
const guard = src.slice(src.indexOf("app.use('*'"), src.indexOf("app.get('/', "));
|
||||
const noTokenBlock = guard.slice(guard.indexOf('if (!token)'), guard.indexOf('const auth'));
|
||||
expect(noTokenBlock).toContain('401');
|
||||
expect(noTokenBlock).not.toContain('return next()');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,20 @@ database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1(arcrun-
|
||||
[vars]
|
||||
ENVIRONMENT = "production"
|
||||
|
||||
# ── Auth guard (t115 二修, fail-closed) ────────────────────────────────────────
|
||||
# The installer generates a random token at deploy time and secrets it into BOTH workers:
|
||||
# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-kbdb)
|
||||
# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-cypher-executor)
|
||||
# cypher sends the token as `Authorization: Bearer <token>` via kbdbBase().
|
||||
# Workflow http_request nodes that hit KBDB directly must include
|
||||
# `Authorization: Bearer __KBDB_TOKEN__` (installer substitutes the value).
|
||||
#
|
||||
# Secret NOT set → writes (POST/PATCH/DELETE) are rejected 401 immediately (fail-closed).
|
||||
# Reads (GET) pass with a server-side warning — old instances survive the upgrade
|
||||
# window until both workers receive the secret at the same time.
|
||||
# Secret SET → all non-health routes require correct Bearer; / and /health exempt.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Optional embed module (issue #7 / SDD T2.4) ────────────────────────────────
|
||||
# Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true
|
||||
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed
|
||||
|
||||
@@ -178,6 +178,118 @@
|
||||
驗證:findBestNodeMatch 6 案(空清單/無命中/精確子字串/多命中取最短/CJK 未正規化/大小寫)+
|
||||
integration 4 案(有鄰居直接回、0 鄰居 fuzzy 命中、0 鄰居 fuzzy 無命中誠實回 0、t95+t96 連動)。
|
||||
|
||||
- [x] **t97 庫目錄只顯示用戶同步進來的(2026-07-28,任務層小改)**:
|
||||
來源=leo 裁定「用戶沒加上的庫,不要自作主張給它加上」。
|
||||
t97a:bootstrap 後不再預埋 `kb` 庫(index.html firstsetup 移除 POST /portal/admin/libraries 種子 call)。
|
||||
t97b:GET /portal/admin/libraries auto 段新增 `n === 'general'` 過濾——general 是系統「未標庫」
|
||||
fallback 桶,不是用戶加的,不在目錄露臉;資料照舊、B5 權限語意不動。
|
||||
驗證:portal-admin.test.ts 新增「auto 過濾 general」1 案(mock KBDB /entries/libraries → ['kb','general','notes'],
|
||||
回應 names 含 kb/notes,不含 general)。
|
||||
|
||||
- [x] **t114 拿掉「登記到目錄」兩段式(2026-07-28,任務層小改)**:
|
||||
來源=leo 裁定「掃進來的就是要進目錄…加入目錄這件小事還要分兩段做?是在攻打用戶嗎」。
|
||||
修:`renderAdminLibs()` 拿掉 auto/已登記視覺分岔,auto 庫直接以完整卡片顯示(保留「同步自動出現」tag,
|
||||
去掉「登記到目錄後可以改顯示名」描述文字);移除「登記到目錄」按鈕與 lib-adopt event handler;
|
||||
空狀態文字改「同步小幫手還沒送來任何庫…庫會自動出現在這裡」。
|
||||
顯示名:此輪 auto 庫唯讀(與已登記庫現行行為一致,實作成本最低;後續若需可補 auto-adopt on PATCH)。
|
||||
搜尋不依賴登記(搜尋走 /portal/data/search → server 注入 library filter,與庫目錄登記簿無關)。
|
||||
驗證:HTML 殼測試補「無 lib-adopt、無登記到目錄」斷言;既有 HTML 斷言(零租戶字串等)不回退。
|
||||
|
||||
- [x] **t115 kbdb 全域認證中介層(2026-07-28,安全洞熱修;二修 2026-07-28)**:
|
||||
實證:不帶任何憑證直打 `arcrun-kbdb.<sub>.workers.dev/entries` → 200 回真實知識;POST 直寫
|
||||
成功。B5 權限做在 cypher/portal 層,繞過 portal 直打 kbdb 全破,隱私賣點(原文不出機)形同虛設。
|
||||
一修(已在樹上)兩個缺陷:① `if (env.KBDB_INTERNAL_TOKEN)` 才擋 = 沒設 secret 洞照開(fail-open);
|
||||
② workflows/rag-ingest-card.local.yaml(arcrun-rag repo)有 3 處 `__KBDB_BASE__`
|
||||
(post_block/post_triplet 走 http_request 直打 kbdb),rag-chat/graph-neighbors/takedown 同樣;
|
||||
token 生效後收卡與查詢全 401。
|
||||
二修(本次):
|
||||
① `kbdb/src/index.ts` middleware 改 fail-closed:
|
||||
- 未設 token → POST/PATCH/DELETE/PUT 直接 401(fail-closed for writes);
|
||||
GET 記 console.warn 後放行(讀取升級窗口,老實例不整個炸)。
|
||||
- 已設 token → 非 health 路由全部要求 Bearer(行為同一修)。
|
||||
② `kbdb/tests/auth.test.ts` 改 12 項(含 PATCH/DELETE 無 token→401;原「無 token POST 過」改為 401)。
|
||||
③ `kbdb/src/types.ts`+`kbdb/wrangler.toml` 說明改為 fail-closed 語意。
|
||||
④ workflow http_request 節點(arcrun-rag 側):規格見本項末「給安裝器的規格」段,由總管派 arcrun-rag。
|
||||
cypher 不需改(五處已有 `if (KBDB_INTERNAL_TOKEN) headers[Authorization]=Bearer`)。
|
||||
老實例升級路徑:安裝器部署時自動生成同一把 token → `wrangler secret put KBDB_INTERNAL_TOKEN` 注入
|
||||
kbdb 與 cypher 兩個 worker;workflow yaml 同批替換 `__KBDB_TOKEN__`(見規格)即封口。
|
||||
kbdb_upsert_block WASM 指向死路由 `/blocks`(設 token 前後都壞,不新增破壞)。
|
||||
|
||||
- [x] **t116 圖搜尋靜默失敗——portal 補傳 kbdb_base(2026-07-29,任務層小改)**:
|
||||
診斷:graph_neighbors workflow 的 fetch_triplets url 含 `{{input.kbdb_base}}/records/...`,
|
||||
但 portal 呼叫 executeWebhookGraph 時未傳 kbdb_base → URL 解析失敗 → `HTTP request failed`。
|
||||
修法(b 案 雙保險):portal-data.ts 補傳 `kbdb_base: env.KBDB_BASE_URL ?? ''`;
|
||||
yaml 端(`__KBDB_BASE__` 安裝期替換)由總管派 arcrun-rag,詳見本項末規格段。
|
||||
執行範圍:`cypher-executor/src/routes/portal-data.ts`(補傳 kbdb_base)。
|
||||
|
||||
- [x] **t117 三元組寫入靜默失敗——FOREACH 全失敗浮出水面(2026-07-29,任務層小改)**:
|
||||
診斷:youlin 實例 triplet=0,懷疑 post_triplet http_request 401 被靜默吞掉;
|
||||
FOREACH 收集到全 `success:false` 的 iterResults 後回 `{success:true, data:{results:[...]}}` —— 呼叫端不知失敗。
|
||||
兩層修法:
|
||||
① wasi-shim.ts http_request catch:改寫錯誤 envelope(含 fetch 原始 message)到 WASM 輸出,
|
||||
取代只 return 1(WASM 寫 "HTTP request failed");
|
||||
② graph-executor.ts FOREACH:若全部 iterResults 均 `success===false`,拋 Error(含首項 status code)
|
||||
→ catch 轉 ExecutionError → executeWebhookGraph 回 `{success:false, error:"..."}`。
|
||||
執行範圍:`cypher-executor/src/lib/wasi-shim.ts`、`cypher-executor/src/graph-executor.ts`。
|
||||
測試:vitest 新增兩條(FOREACH 全失敗→error 含 status;wasi-shim catch 路徑)。
|
||||
|
||||
- [x] **t122 萃取引擎金鑰雲端下發(2026-07-29)**:
|
||||
來源=總管確認:daemon/config 回傳 extractor:'claude' 寫死且不含金鑰,封測者萃取全滅。
|
||||
修:① `POST /portal/admin/extractor`(admin 閘)存 `{engine, gemini_api_key?, llm_model?}` 到 KV
|
||||
key=`{tenant}:portal:extractor_config`,金鑰不落 log;
|
||||
② `GET /portal/admin/extractor`(回 engine + has_key:bool,不回明文);
|
||||
③ `POST /portal/daemon/config` 回傳改讀上述設定(未設→預設 gemma;gemma+有 key → 含金鑰下發);
|
||||
④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊(引擎選擇+金鑰輸入+提示重連)。
|
||||
測試:vitest 新增 3 案(未設定→下發 gemma 無金鑰;設定後→下發含金鑰;GET 不回明文)。
|
||||
|
||||
- [x] **t128 圖搜尋仍回 0——graph_neighbors 補傳 template(2026-07-29,任務層小改)**:
|
||||
診斷:t116 只補了 kbdb_base,但 fetch_triplets.url 同時含 `{{input.template}}`;
|
||||
portal 呼叫 executeWebhookGraph 未傳 template → URL 變 `/records/by-template/?owner_id=...` → count=0。
|
||||
總管實測:手動補 `"template":"triplet"` → count=1(企業版功能解鎖)。
|
||||
修:portal-data.ts graph_neighbors 呼叫補 `template: 'triplet'`。
|
||||
執行範圍:`cypher-executor/src/routes/portal-data.ts` 一行。
|
||||
測試:portal-data.test.ts 新增整合案(t128 describe,workflow 執行不崩即代表 template 進到 context)。
|
||||
workflow input 完整核對清單(portal-data.ts 所有 executeWebhookGraph 呼叫):
|
||||
- graph_neighbors:node ✅、depth ✅、namespace ✅、owner ✅、kbdb_base ✅(t116)、template ✅(本次)
|
||||
- rag_chat:question ✅;namespace/kbdb_base 未核實(rag_chat workflow yaml 在 arcrun-rag,非本 repo)
|
||||
- takedown:不在 portal-data.ts,在 arcrun-rag 端(dashboard/安裝器),超出本 repo 範圍
|
||||
|
||||
- [x] **t130 新實例缺 triplet template → 總圖永遠空(2026-07-29,任務層小改)**:
|
||||
真因:`PORTAL_TEMPLATE_SEEDS` 只有 `portal_user`+`portal_library`;`rag_ingest_card.post_triplet`
|
||||
打 `POST /records {template:'triplet'}` → 新實例回 400「template not found: triplet」→ 三元組全滅。
|
||||
修:`portal-seeds.ts` 補 `triplet` seed(slots 來源:2026-07-19 kbdb_list_templates 核實 15 槽 + library)。
|
||||
呼叫路徑驗證(`ensurePortalTemplates` 全四掛點):
|
||||
① `POST /init/seed`(acr init 觸發)← 主線,新裝必經
|
||||
② `POST /portal/admin/bootstrap`(首次 admin 設定)
|
||||
③ `POST /portal/daemon/libraries`(daemon 登記資料夾→庫)
|
||||
④ `POST /portal/admin/libraries`(admin 手動新增庫)
|
||||
既有壞實例補救:任一掛點重跑即補建(冪等),e.g. 重跑 acr init 或 bootstrap。
|
||||
執行範圍:`cypher-executor/src/lib/portal-seeds.ts`(新增 triplet seed);
|
||||
`cypher-executor/tests/portal-auth.test.ts`(mockTemplatesExist 補 triplet + t130 3 條新測試);
|
||||
`cypher-executor/tests/portal-admin.test.ts`(mockTemplatesExist 補 triplet)。
|
||||
測試:純資料驗證(seed 含 triplet + 必要 slots)+冪等驗(已存→existing)+自動補建(404→POST→created)。
|
||||
|
||||
- [x] **t129 AI 問答出處重複一整頁——後端按 page_name 去重(2026-07-29,任務層小改)**:
|
||||
診斷:一張卡拆成 3-5 block,每 block 各回一筆 source(同 page_name)→ 前端列一整頁重複。
|
||||
選後端修(/portal/data/chat)理由:出處去重是資料清潔,跟前端渲染無關;改後端不需動 HTML。
|
||||
修:portal-data.ts 新增純函式 `dedupeSourcesByPage()`(Map 按 page_name/page 去重,hit_count > 1 時附計數);
|
||||
chat 路由 rawSources 先過去重再回。
|
||||
執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。
|
||||
測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。
|
||||
|
||||
- [x] **t135 庫目錄移除(2026-07-29,任務層小改)**:
|
||||
來源=leo 裁定「需要加移除按鈕,別人裝錯我沒辦法幫他弄,需要可以自主」。
|
||||
後端:`DELETE /portal/admin/libraries/:id`(刪登記簿 record,資料不動)+
|
||||
`DELETE /portal/admin/libraries/by-name/:name`(auto 庫;body `confirm:<庫名>` 才執行;
|
||||
呼叫 KBDB `PATCH /entries/deprecate-by-library`,entries 標 deprecated → 從 auto 清單消失)。
|
||||
KBDB:`deleteRecord` action + `DELETE /records/:id` route;
|
||||
`deprecateEntriesByLibrary` action + `PATCH /entries/deprecate-by-library` route(此路由在 `/:id` 之前)。
|
||||
Daemon hint(根治只增不減):`POST /portal/daemon/libraries` 存 active lib names 到 KV(TTL 48h);
|
||||
`GET /portal/admin/libraries` 讀 KV 後對登記庫標 `daemon_watching: false`(若 daemon 有回報但未含該庫)。
|
||||
前端:每張庫卡片加「移除」按鈕(登記庫=`confirm()` 對話框;auto 庫=`prompt()` 輸入庫名確認);
|
||||
移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。
|
||||
測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403;by-name 成功/無confirm/confirm不符/非admin403)。
|
||||
|
||||
## 第二波(不在本 SDD 動工範圍,掛號)
|
||||
|
||||
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
||||
|
||||
Reference in New Issue
Block a user