+
+
@@ -552,6 +565,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
token: localStorage.getItem('arcrun_console_session') || '',
tenant: '',
chip: '', // entry_type filter('' = 全部)
+ library: '', // library filter('' = 全館;藏書地圖點庫卡片設定,M5/R4)
semantic: false,
cardId: '',
graphCenter: '',
@@ -860,6 +874,115 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
searchInited = true;
renderChips();
loadScale();
+ loadLibraryMap();
+ }
+
+ // ── 藏書地圖(library-map SDD M5/R4)──
+ // 資料源:GET /kbdb/map(cypher 代轉 KBDB 基本盤,kbdb-proxy.ts——同 /kbdb/search 慣例)。
+ // 兩段式渲染:先用全館列表(top_entities=名字 top3)立即出卡片,再逐庫拉 /kbdb/map/:library
+ // 補 degree/bridges/commit_hash(庫數少;詳圖拉不到就維持名字版——加分項不擋渲染)。
+ // 空陣列/錯誤=誠實空狀態,只佔地圖區塊,不擋下方搜尋。
+ var LM = { libs: null, details: {} }; // 地圖快取:點卡片重繪「搜尋中」標記時不用重打 API
+ function lmHonest(head, body) {
+ $('lm-cards').innerHTML = '
' + esc(head) + '
' + body + '
';
+ $('lm-bridges').innerHTML = '';
+ $('lm-meta').textContent = '';
+ }
+ function lmEntityLine(ents) {
+ // ents:詳圖版 [{name, degree}] 或全館列表版 ['name']——兩形都渲染,degree 有才顯示
+ return (ents || []).slice(0, 5).map(function (t) {
+ var name = typeof t === 'string' ? t : (t && t.name) || '';
+ var deg = t && typeof t === 'object' && typeof t.degree === 'number' ? t.degree : null;
+ return '
' + esc(name) + (deg != null ? '・' + deg + '' : '') + '';
+ }).join(' ');
+ }
+ function renderLmCards(libs, details) {
+ $('lm-meta').textContent = libs.length + ' 個庫・點卡片進該庫搜尋';
+ $('lm-cards').innerHTML = libs.map(function (l) {
+ var d = details[l.library];
+ var ents = d && d.top_entities && d.top_entities.length ? d.top_entities : l.top_entities;
+ // 更新時間感:commit_hash(詳圖有才顯示,短碼)+ updated_at(epoch 秒,台北時間)
+ var stamp = (d && d.commit_hash ? '
' + esc(String(d.commit_hash).slice(0, 7)) + '・' : '') +
+ '更新 ' + esc(fmtDateTime(l.updated_at) || '—');
+ return '
' +
+ '
' + esc(l.library) + (S.library === l.library ? ' 搜尋中' : '') + '
' +
+ '
' + (l.narrative ? esc(l.narrative) : '(此庫尚無 narrative——recompute 時可帶入)') + '
' +
+ (ents && ents.length ? '
' + lmEntityLine(ents) + '
' : '') +
+ '
' + (Number(l.triplet_count) || 0) + ' 三元組' +
+ '' + stamp + '
';
+ }).join('');
+ }
+ function renderLmBridges(details) {
+ // 跨庫橋:詳圖 bridges=[{entity, libraries[]}],多庫詳圖去重後列一小區;沒資料就整區不出
+ var seen = {}, rows = [];
+ Object.keys(details).forEach(function (k) {
+ (details[k].bridges || []).forEach(function (b) {
+ if (!b || !b.entity || seen[b.entity]) return;
+ seen[b.entity] = true;
+ rows.push(b);
+ });
+ });
+ if (!rows.length) { $('lm-bridges').innerHTML = ''; return; }
+ $('lm-bridges').innerHTML = '
' +
+ '
跨庫橋(同一 entity 出現在多個庫)
' +
+ '
' + rows.slice(0, 12).map(function (b) {
+ return '' + esc(b.entity) + '' + esc((b.libraries || []).join(' ↔ ')) + '';
+ }).join('') + '
';
+ }
+ function loadLibraryMap() {
+ fetch('/kbdb/map', { headers: apiHeaders() })
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
+ .then(function (x) {
+ if (!x.ok || x.d.success === false) {
+ lmHonest('讀不到藏書地圖', esc(x.d.error || ('HTTP ' + x.status)) + '
不影響下方搜尋,可直接搜全庫。');
+ return;
+ }
+ var libs = x.d.libraries || [];
+ if (!libs.length) {
+ lmHonest('還沒有藏書地圖', '還沒有任何庫跑過重算——對 KBDB 呼
POST /map/recompute?library=庫名 backfill 後,這裡會出現全館導覽。
不影響下方搜尋,可直接搜全庫。');
+ return;
+ }
+ LM.libs = libs; LM.details = {};
+ renderLmCards(libs, {});
+ Promise.allSettled(libs.map(function (l) {
+ return fetch('/kbdb/map/' + encodeURIComponent(l.library), { headers: apiHeaders() })
+ .then(function (r) { return r.ok ? r.json() : null; });
+ })).then(function (rs) {
+ var details = {};
+ rs.forEach(function (r) {
+ if (r.status === 'fulfilled' && r.value && r.value.map && r.value.map.library) details[r.value.map.library] = r.value.map;
+ });
+ LM.details = details;
+ renderLmCards(libs, details);
+ renderLmBridges(details);
+ });
+ })
+ .catch(function (e) { lmHonest('地圖服務不可達', esc(friendlyErr(e)) + '
不影響下方搜尋,可直接搜全庫。'); });
+ }
+ // 點庫卡片=設 library filter(/kbdb/search 既有 library 參數,多值逗號分隔——此處單庫)進庫搜尋
+ $('lm-cards').addEventListener('click', function (ev) {
+ var t = ev.target.closest('[data-lib]');
+ if (!t) return;
+ S.library = t.getAttribute('data-lib');
+ renderLibBar();
+ if (LM.libs) renderLmCards(LM.libs, LM.details);
+ $('se-q').focus();
+ if ($('se-q').value.trim()) doSearch();
+ toast('已鎖定庫「' + S.library + '」——輸入關鍵字搜這個庫');
+ });
+ function renderLibBar() {
+ $('se-libbar').innerHTML = S.library
+ ? '
' +
+ '庫 ' + esc(S.library) + '' +
+ '
'
+ : '';
+ var clear = document.getElementById('se-libclear');
+ if (clear) clear.addEventListener('click', function () {
+ S.library = '';
+ renderLibBar();
+ if (LM.libs) renderLmCards(LM.libs, LM.details);
+ if ($('se-q').value.trim()) doSearch();
+ });
}
function renderChips() {
$('se-chips').innerHTML = CHIPS.map(function (c, i) {
@@ -916,6 +1039,8 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
$('se-results').innerHTML = '';
var url = '/kbdb/search?q=' + encodeURIComponent(q) + '&mode=' + (S.semantic ? 'semantic' : 'keyword');
if (S.chip) url += '&entry_type=' + encodeURIComponent(S.chip);
+ // 藏書地圖進庫(M5/R4):library filter=/kbdb/search 既有參數(portal-auth P1 順延項),不新造
+ if (S.library) url += '&library=' + encodeURIComponent(S.library);
fetch(url, { headers: apiHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
@@ -925,7 +1050,8 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
$('se-banner').innerHTML = '
語意搜尋尚未啟用
語意搜尋用「意思」找資料,不是字面比對。
' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '
';
}
var entries = d.entries || [];
- $('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + '・搜尋範圍=全庫(含 14-E 遺產)';
+ $('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +
+ '・搜尋範圍=' + (S.library ? '庫「' + S.library + '」' : '全庫(含 14-E 遺產)');
if (!entries.length) {
$('se-results').innerHTML = '
找不到「' + esc(q) + '」——換個關鍵字試試。
';
return;
diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts
index c83dae0..8c0f441 100644
--- a/cypher-executor/src/routes/kbdb-proxy.ts
+++ b/cypher-executor/src/routes/kbdb-proxy.ts
@@ -218,6 +218,44 @@ kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
}
});
+// ── map(藏書地圖,library-map SDD M5/R4:console 首頁全館地圖)──────────────────
+//
+// 純轉發(rule 07):聚合真身在 KBDB 基本盤(kbdb/src/routes/map.ts,M2 已 merge)。
+// owner_id **不強制注入、只選擇性透傳**——與 MCP kbdb_get_map(M4)同義,理由兩層:
+// 1. map block 是庫級聚合摘要(narrative+top entities+規模),非租戶私資料;
+// 2. 現行 backfill recompute 未帶 owner(map block owner_id=NULL)——若比照 search
+// 強制注入租戶,`e.owner_id = ?` 會把 NULL 列全濾掉 → 首頁永遠假空狀態(不誠實)。
+// 仍要求 X-Arcrun-API-Key(與本檔其他端點同閘;console 登入後才有 tenant 字串)。
+
+// GET /kbdb/map — 全館地圖(每庫一行:library+narrative+top 3 entities+triplet_count+updated_at)。
+kbdbProxyRouter.get('/kbdb/map', async (c) => {
+ if (!tenant(c)) return c.json(NEED_KEY, 401);
+ const { base, headers } = kbdbBase(c.env);
+ const owner = c.req.query('owner_id');
+ const qs = owner ? `?owner_id=${encodeURIComponent(owner)}` : '';
+ try {
+ const res = await fetch(`${base}/map${qs}`, { headers });
+ return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
+ } catch (e) {
+ // KBDB 不可達 → 誠實回報(前端顯示「地圖服務不可達」,不擋首頁其他功能)
+ return c.json({ success: false, error: `KBDB 不可達(${base}):${e instanceof Error ? e.message : String(e)}` }, 502);
+ }
+});
+
+// GET /kbdb/map/:library — 該庫詳圖(完整 top_entities 帶 degree/relation_profile/bridges/commit_hash)。
+kbdbProxyRouter.get('/kbdb/map/:library', async (c) => {
+ if (!tenant(c)) return c.json(NEED_KEY, 401);
+ const { base, headers } = kbdbBase(c.env);
+ const owner = c.req.query('owner_id');
+ const qs = owner ? `?owner_id=${encodeURIComponent(owner)}` : '';
+ try {
+ const res = await fetch(`${base}/map/${encodeURIComponent(c.req.param('library'))}${qs}`, { headers });
+ return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
+ } catch (e) {
+ return c.json({ success: false, error: `KBDB 不可達(${base}):${e instanceof Error ? e.message : String(e)}` }, 502);
+ }
+});
+
// PATCH /kbdb/entries/:id — 更新單筆 entry。owner_id 不可被改(剝除 caller 自帶的 owner_id)。
kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
diff --git a/cypher-executor/tests/console-library-map-page.test.ts b/cypher-executor/tests/console-library-map-page.test.ts
new file mode 100644
index 0000000..173acc8
--- /dev/null
+++ b/cypher-executor/tests/console-library-map-page.test.ts
@@ -0,0 +1,57 @@
+/**
+ * GET /console 頁面測試 — 藏書地圖首頁區塊(library-map SDD M5/R4)
+ *
+ * console 是單檔 HTML 薄殼(server 端零資料請求,client JS 打 /kbdb/map),所以頁面測試
+ * 驗的是「殼有沒有長對」:route 200、地圖區塊在搜尋框上方、進庫搜尋接的是既有 library
+ * 參數、空狀態/錯誤誠實文案在(不擋其他功能)。轉發行為真身在 kbdb-map-proxy.test.ts。
+ */
+import { SELF } from 'cloudflare:test';
+import { describe, it, expect } from 'vitest';
+
+async function page(): Promise<{ status: number; html: string }> {
+ const res = await SELF.fetch('http://localhost/console');
+ return { status: res.status, html: await res.text() };
+}
+
+describe('GET /console — 藏書地圖區塊', () => {
+ it('route 200、HTML 含地圖區塊(lm-section/lm-cards/lm-bridges)', async () => {
+ const { status, html } = await page();
+ expect(status).toBe(200);
+ expect(html).toContain('藏書地圖');
+ expect(html).toContain('id="lm-section"');
+ expect(html).toContain('id="lm-cards"');
+ expect(html).toContain('id="lm-bridges"');
+ });
+
+ it('地圖區塊在搜尋框上方(DOM 順序:lm-section 先於 se-q)', async () => {
+ const { html } = await page();
+ expect(html.indexOf('id="lm-section"')).toBeGreaterThan(-1);
+ expect(html.indexOf('id="lm-section"')).toBeLessThan(html.indexOf('id="se-q"'));
+ });
+
+ it('資料源接 /kbdb/map(cypher 代轉慣例)+逐庫詳圖 /kbdb/map/', async () => {
+ const { html } = await page();
+ expect(html).toContain("fetch('/kbdb/map'");
+ expect(html).toContain("'/kbdb/map/' + encodeURIComponent");
+ });
+
+ it('點庫卡片=既有搜尋 library 參數進庫(不新造參數)', async () => {
+ const { html } = await page();
+ expect(html).toContain("'&library=' + encodeURIComponent(S.library)");
+ expect(html).toContain('data-lib');
+ });
+
+ it('空狀態/錯誤分支=誠實文案(recompute backfill 指引)且不擋搜尋', async () => {
+ const { html } = await page();
+ expect(html).toContain('還沒有藏書地圖');
+ expect(html).toContain('/map/recompute');
+ expect(html).toContain('不影響下方搜尋');
+ expect(html).toContain('地圖服務不可達');
+ });
+
+ it('品牌字樣走 CONSOLE_BRAND 變數(未設=預設 Arcrun),地圖區塊沒硬編品牌', async () => {
+ const { html } = await page();
+ // 測試環境未設 CONSOLE_BRAND → 預設 Arcrun(#21 rebrand:字樣由變數注入,非硬編 Mira)
+ expect(html).toContain('
Arcrun Console');
+ });
+});
diff --git a/cypher-executor/tests/kbdb-map-proxy.test.ts b/cypher-executor/tests/kbdb-map-proxy.test.ts
new file mode 100644
index 0000000..6314209
--- /dev/null
+++ b/cypher-executor/tests/kbdb-map-proxy.test.ts
@@ -0,0 +1,134 @@
+/**
+ * GET /kbdb/map + /kbdb/map/:library proxy 測試(library-map SDD M5/R4,console 首頁藏書地圖資料源)
+ *
+ * 驗證 IO 接線(聚合真身在 KBDB 基本盤 kbdb/src/routes/map.ts,M2 已測;這裡只測轉發):
+ * 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB(與本 proxy 其他端點同閘)
+ * 2. 轉發:/kbdb/map → base /map;/kbdb/map/:library → base /map/:library(庫名 URL-encode)
+ * 3. owner_id **不強制注入、只選擇性透傳**(與 MCP kbdb_get_map 同義)——現行 backfill 的
+ * map block owner_id=NULL,強制注入租戶會讓首頁永遠假空狀態
+ * 4. 空陣列原樣透傳(前端誠實空狀態的資料依據);404/KBDB 不可達 → 誠實回報不假裝
+ *
+ * KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
+ * disableNetConnect——測試絕不外連。
+ */
+import { SELF, fetchMock } from 'cloudflare:test';
+import { beforeAll, afterEach, describe, it, expect } from 'vitest';
+
+const KEY = { 'X-Arcrun-API-Key': 'leo' };
+
+beforeAll(() => {
+ fetchMock.activate();
+ fetchMock.disableNetConnect();
+});
+afterEach(() => fetchMock.assertNoPendingInterceptors());
+
+describe('GET /kbdb/map — 租戶閘', () => {
+ it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
+ const res = await SELF.fetch('http://localhost/kbdb/map');
+ expect(res.status).toBe(401);
+ });
+ it('/kbdb/map/:library 同閘 → 401', async () => {
+ const res = await SELF.fetch('http://localhost/kbdb/map/kb');
+ expect(res.status).toBe(401);
+ });
+});
+
+describe('GET /kbdb/map — 全館地圖轉發', () => {
+ it('轉發 base /map、回應原樣透傳(不注入 owner_id)', async () => {
+ const payload = {
+ success: true,
+ libraries: [
+ { library: 'kb', narrative: 'leo 的知識庫主庫', top_entities: ['00-INDEX'], triplet_count: 111, updated_at: 1784451880 },
+ ],
+ count: 1,
+ };
+ fetchMock
+ .get('https://kbdb.test')
+ .intercept({ path: '/map', method: 'GET' })
+ .reply(200, payload);
+ const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as typeof payload;
+ expect(data.count).toBe(1);
+ expect(data.libraries[0].library).toBe('kb');
+ expect(data.libraries[0].triplet_count).toBe(111);
+ });
+
+ it('空陣列原樣透傳(前端據此顯示誠實空狀態,指引 recompute backfill)', async () => {
+ fetchMock
+ .get('https://kbdb.test')
+ .intercept({ path: '/map', method: 'GET' })
+ .reply(200, { success: true, libraries: [], count: 0 });
+ const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { libraries: unknown[]; count: number };
+ expect(data.libraries).toEqual([]);
+ expect(data.count).toBe(0);
+ });
+
+ it('caller 帶 owner_id → 透傳給 base(選擇性,非強制注入)', async () => {
+ fetchMock
+ .get('https://kbdb.test')
+ .intercept({ path: '/map', query: { owner_id: 'someone' }, method: 'GET' })
+ .reply(200, { success: true, libraries: [], count: 0 });
+ const res = await SELF.fetch('http://localhost/kbdb/map?owner_id=someone', { headers: KEY });
+ expect(res.status).toBe(200);
+ });
+});
+
+describe('GET /kbdb/map/:library — 單庫詳圖轉發', () => {
+ it('轉發 base /map/:library、詳圖(degree/bridges/commit_hash)原樣透傳', async () => {
+ const map = {
+ record_id: 'e_1',
+ library: 'kb',
+ narrative: 'leo 的知識庫主庫',
+ top_entities: [{ name: '00-INDEX', degree: 29 }],
+ relation_profile: [{ predicate: '連結至', count: 48 }],
+ bridges: [{ entity: 'Gitea', libraries: ['kb', 'notes'] }],
+ triplet_count: 111,
+ commit_hash: 'abc1234def',
+ status: 'active',
+ updated_at: 1784451880,
+ };
+ fetchMock
+ .get('https://kbdb.test')
+ .intercept({ path: '/map/kb', method: 'GET' })
+ .reply(200, { success: true, map });
+ const res = await SELF.fetch('http://localhost/kbdb/map/kb', { headers: KEY });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { map: typeof map };
+ expect(data.map.top_entities[0].degree).toBe(29);
+ expect(data.map.bridges[0].libraries).toEqual(['kb', 'notes']);
+ });
+
+ it('庫名 URL-encode 轉發(中文庫名不炸)', async () => {
+ fetchMock
+ .get('https://kbdb.test')
+ .intercept({ path: `/map/${encodeURIComponent('筆記庫')}`, method: 'GET' })
+ .reply(200, { success: true, map: { library: '筆記庫' } });
+ const res = await SELF.fetch(`http://localhost/kbdb/map/${encodeURIComponent('筆記庫')}`, { headers: KEY });
+ expect(res.status).toBe(200);
+ });
+
+ it('base 404(庫沒地圖)→ 原樣透傳 404,不假裝有資料', async () => {
+ fetchMock
+ .get('https://kbdb.test')
+ .intercept({ path: '/map/nope', method: 'GET' })
+ .reply(404, { success: false, error: 'not found' });
+ const res = await SELF.fetch('http://localhost/kbdb/map/nope', { headers: KEY });
+ expect(res.status).toBe(404);
+ const data = (await res.json()) as { success: boolean };
+ expect(data.success).toBe(false);
+ });
+});
+
+describe('KBDB 不可達 → 誠實 502(前端顯示地圖不可達,不擋搜尋)', () => {
+ it('/kbdb/map fetch 炸 → 502 + success:false', async () => {
+ // 不掛 interceptor:disableNetConnect 下 fetch 直接 throw → route catch → 502
+ const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
+ expect(res.status).toBe(502);
+ const data = (await res.json()) as { success: boolean; error: string };
+ expect(data.success).toBe(false);
+ expect(data.error).toContain('KBDB 不可達');
+ });
+});
diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md
index 7352e6f..f6aa288 100644
--- a/system-dev/wiki/status.md
+++ b/system-dev/wiki/status.md
@@ -15,6 +15,17 @@ metadata:
## 📍 當前位置
+> **2026-07-19(#39 藏書地圖 M5,分支 `feat/console-library-map-home`)**:**library-map SDD M5(GUI
+> 首頁)PR 已開,等審+gated 部署(merge 後需 leo 閘 redeploy cypher-executor)**。R4 落點裁定=
+> console **總庫搜尋頁搜尋框上方**(rag profile 該頁即首頁;full profile 它是全館入口——駕駛艙是
+> 狀態面不是庫導覽面,地圖跟搜尋同頁「點庫卡片→帶 library filter 進庫搜尋」動線最短、一份實作
+> 兩 profile 全吃)。兩段式渲染:GET /kbdb/map 先出庫卡片(narrative+top entities+triplet 數+
+> 更新時間),逐庫 GET /kbdb/map/:library 補 degree/跨庫 bridges/commit_hash(詳圖失敗維持名字
+> 版不擋渲染);proxy 端(kbdb-proxy.ts)owner_id **不強制注入只透傳**(同 M4 MCP 語意——現行
+> backfill map block owner=NULL,強制注入=首頁永遠假空狀態);/map 空/錯誤=誠實空狀態(指引
+> recompute backfill)不擋搜尋。測試:proxy 9 新測+console 頁 6 新測全過;tsc 16 行既有
+> ExecutionContext.tracing 錯誤與 executor.test 1 失敗=main 基線就有(stash 對照實證),非本分支引入。
+>
> **2026-07-19(#39 藏書地圖 M4,分支 `feat/mcp-library-map-inject`)**:**library-map SDD M4 PR 已開,
> 等審+gated 部署(merge 後需 leo 閘 redeploy arcrun-mcp)**。兩件(design §4):
> ① MCP tool `kbdb_get_map`(無參數=全館每庫一行;帶 library=詳圖,slot JSON 字串容錯 parse 成