diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 0a628b4..c17a5b5 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -97,14 +97,19 @@ function canReadLibrary(userLibraries: string[], library: string): boolean { /** * 搜尋殘影過濾(**Arcrun#46 上游修好前的 portal 端治標**——舊管線的 deprecated 產物還躺在 * KBDB 裡污染搜尋結果;根治=上游清資料/重建索引,那修好後這段可整段拔掉)。 - * 濾掉:metadata_json.status === 'deprecated' 的 entry、content 以「(舊管線產物」開頭的 entry。 + * 濾掉:metadata_json.status === 'deprecated' 的 entry、content 以「(舊管線產物」開頭的 entry、 + * 內部型別 entry(value=slot 值外漏的無標題雜項列、workflow=工作流定義——都是系統內部件, + * 不是給搜尋用戶看的內容;2026-07-18 leo 客戶測試回饋「搜尋結果偶見無標題雜項列」)。 * metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。 * 純函式(單測用 export)。 */ -export function filterDeprecatedEntries( +const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']); + +export function filterDeprecatedEntries( entries: T[], ): T[] { return entries.filter((e) => { + if (e.entry_type && INTERNAL_ENTRY_TYPES.has(e.entry_type)) return false; try { const meta = JSON.parse(e.metadata_json ?? 'null') as { status?: unknown } | null; if (meta && meta.status === 'deprecated') return false; @@ -235,6 +240,53 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => }), ); +// GET /portal/data/graph/overview — 書庫總圖(Arcrun#39 藏書地圖的 GUI 資料切片): +// 全租戶 active 三元組 → {nodes:[{name,degree}], edges:[{subject,predicate,object}]}。 +// 權限=與 neighbors 同一道 D-4 graph 粗閘;資料直讀 KBDB records(與 search 同 kbdbFetch 路徑, +// 不經 graph plugin、不撞 1042)。邊數上限 500(demo 量級遠低於此;超限誠實截斷並回 truncated)。 +portalDataRouter.get('/portal/data/graph/overview', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const libraries = parseLibraries(auth.user.values.libraries); + if (!(await hasGraphAccess(c.env, libraries))) { + return c.json({ error: '無知識圖譜檢視權限' }, 403); + } + const tenant = portalTenant(c.env); + const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`); + if (!res.ok) { + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + } + const body = (await res.json().catch(() => null)) as + | { records?: { values?: Record }[] } + | null; + const records = body && Array.isArray(body.records) ? body.records : []; + const EDGE_CAP = 500; + const seen = new Set(); + const edges: { subject: string; predicate: string; object: string }[] = []; + const degree = new Map(); + let truncated = false; + for (const r of records) { + const v = r && typeof r.values === 'object' && r.values ? r.values : null; + if (!v) continue; + if (v.status === 'deprecated') continue; + const s = typeof v.subject === 'string' ? v.subject.trim() : ''; + const o = typeof v.object === 'string' ? v.object.trim() : ''; + if (!s || !o) continue; + const p = typeof v.predicate === 'string' ? v.predicate : ''; + const key = `${s}${p}${o}`; + if (seen.has(key)) continue; + seen.add(key); + if (edges.length >= EDGE_CAP) { truncated = true; break; } + edges.push({ subject: s, predicate: p, object: o }); + degree.set(s, (degree.get(s) ?? 0) + 1); + degree.set(o, (degree.get(o) ?? 0) + 1); + } + const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d })); + return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated }); + }), +); + // GET /portal/data/chat?question=... — AI 問答(portal-demo-suite)。 // 設計哲學:AI 檢索=用戶手動搜尋同一套——同 search 的 requirePortalUser 閘、同一個租戶資料面, // 只是把「人下關鍵字」換成「workflow 代查再作答」;前端不因走 AI 多拿任何權限。 diff --git a/cypher-executor/src/routes/portal-ui.ts b/cypher-executor/src/routes/portal-ui.ts index 99b2aa6..74f7970 100644 --- a/cypher-executor/src/routes/portal-ui.ts +++ b/cypher-executor/src/routes/portal-ui.ts @@ -163,6 +163,15 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string { .gcenter { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 104px; height: 104px; border-radius: 50%; background: radial-gradient(circle at 36% 30%,rgba(242,209,148,.95),rgba(232,180,90,.9) 55%,rgba(138,95,30,.95)); display: grid; place-items: center; text-align: center; padding: 8px; box-shadow: 0 0 30px rgba(var(--amber-rgb),.3); } .gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; } .nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; } + /* 總圖(Arcrun#39 GUI 切片):沿用紙感語言——green 章印節點、amber 高度數節點 */ + .mapbox { position: relative; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; } + .mapbox svg { display: block; width: 100%; height: auto; } + .mapnode { cursor: pointer; } + .mapnode circle { fill: rgba(var(--ok-rgb),.12); stroke: rgba(var(--ok-rgb),.45); stroke-width: 1.4; transition: stroke-width .15s; } + .mapnode.hub circle { fill: rgba(var(--amber-rgb),.2); stroke: rgba(var(--amber-rgb),.65); } + .mapnode:hover circle { stroke-width: 3; } + .mapnode text { font-size: 12.5px; fill: var(--ink); pointer-events: none; } + .mapedge { stroke: rgba(var(--ink-rgb),.18); stroke-width: 1; stroke-dasharray: 4 4; } /* ── 管理頁(P4)── */ .sechead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; letter-spacing: .14em; color: var(--amber); margin: 26px 0 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(var(--amber-rgb),.3); } @@ -215,6 +224,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
+ @@ -258,6 +268,19 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
+ +
+
總圖
+
+
+
+ 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版 + (給 AI 注入用的總庫目錄)。 +
+
+
+
@@ -366,6 +389,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
搜尋
+
總圖
上傳
工作流
管理
@@ -505,7 +529,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string { } // ── 路由 ── - var VIEWS = ['search', 'card', 'upload', 'workflows', 'admin', 'settings']; + var VIEWS = ['search', 'map', 'card', 'upload', 'workflows', 'admin', 'settings']; var HOME = 'search'; function allowedViews() { // 工作流/管理/上傳頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404、 @@ -534,7 +558,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string { }); window.scrollTo(0, 0); } - var LOADERS = { card: loadCard, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings }; + var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings }; function route() { var r = currentRoute(); if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; } @@ -568,6 +592,8 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string { $('nav-admin').classList.toggle('hide', p.role !== 'admin'); $('tab-admin').classList.toggle('hide', p.role !== 'admin'); $('mode-graph').classList.toggle('hide', !p.graph_allowed); + $('nav-map').classList.toggle('hide', !p.graph_allowed); + $('tab-map').classList.toggle('hide', !p.graph_allowed); var libs = p.libraries || []; $('se-scope').textContent = libs.indexOf('*') >= 0 ? '範圍:全部知識庫' : ('範圍:' + libs.join('・')); if (!location.hash) location.hash = '#/' + HOME; @@ -832,6 +858,124 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string { .catch(function (er) { $('cd-main').innerHTML = '
請求失敗:' + esc(friendlyErr(er)) + '
'; }); } + // ── 總圖(Arcrun#39 藏書地圖 GUI 切片)── + // 全庫三元組一張網:/portal/data/graph/overview → 手刻力導向佈局(demo 量級,零外部套件)。 + // 佈局用固定種子的 PRNG=同一庫每次長一樣(客戶重整不會跳位)。 + function jumpToGraph(name) { + S.mode = 'graph'; + document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x.getAttribute('data-mode') === 'graph'); }); + nav('search'); + $('se-q').value = name; + doGraphSearch(name); + } + function loadMap() { + $('map-box').innerHTML = '
載入總圖中…
'; + $('map-md-link').innerHTML = SOURCE_WEB_BASE + ? ':00-MAP.md ↗' + : '存在知識庫的 00-MAP.md'; + fetch('/portal/data/graph/overview', { headers: authHeaders() }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (x) { + if (guard401(x.status)) return; + if (!x.ok) { $('map-box').innerHTML = '
' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '
'; return; } + var nodes = x.d.nodes || []; + var edges = x.d.edges || []; + $('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : ''); + if (!nodes.length) { $('map-box').innerHTML = '
知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。
'; return; } + renderMap(nodes, edges); + }) + .catch(function (e) { $('map-box').innerHTML = '
請求失敗:' + esc(friendlyErr(e)) + '
'; }); + } + function renderMap(nodes, edges) { + var N = nodes.length; + var idx = {}; + nodes.forEach(function (n, i) { idx[n.name] = i; }); + var el = []; + edges.forEach(function (e) { + var a = idx[e.subject], b = idx[e.object]; + if (a == null || b == null || a === b) return; + el.push([a, b, e.predicate || '']); + }); + // 固定種子 PRNG(mulberry32):確定性佈局 + var seed = 42; + function rnd() { + seed = (seed + 0x6D2B79F5) | 0; + var t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + var W = 1000, H = Math.max(480, Math.min(760, 200 + N * 14)); + var px = [], py = []; + for (var i = 0; i < N; i++) { + var ang = 2 * Math.PI * i / N; + var rad = Math.min(W, H) * (0.22 + 0.2 * rnd()); + px.push(W / 2 + rad * Math.cos(ang)); + py.push(H / 2 + rad * Math.sin(ang) * 0.75); + } + // 力導向:斥力(全對)+彈簧(邊)+向心,260 輪退火 + var K = Math.max(75, Math.sqrt(W * H / Math.max(N, 1)) * 0.9); + for (var it = 0; it < 260; it++) { + var cool = Math.max(0.06, 1 - it / 260); + var fx = [], fy = []; + for (var a1 = 0; a1 < N; a1++) { fx.push(0); fy.push(0); } + for (var a2 = 0; a2 < N; a2++) { + for (var b2 = a2 + 1; b2 < N; b2++) { + var dx = px[a2] - px[b2], dy = py[a2] - py[b2]; + var d2 = dx * dx + dy * dy + 0.01; + var f = Math.min(28, (K * K) / d2 * 2.2); + var d = Math.sqrt(d2); + fx[a2] += dx / d * f; fy[a2] += dy / d * f; + fx[b2] -= dx / d * f; fy[b2] -= dy / d * f; + } + } + el.forEach(function (e) { + var dx = px[e[0]] - px[e[1]], dy = py[e[0]] - py[e[1]]; + var d = Math.sqrt(dx * dx + dy * dy) + 0.01; + var f = (d - K) * 0.045; + fx[e[0]] -= dx / d * f; fy[e[0]] -= dy / d * f; + fx[e[1]] += dx / d * f; fy[e[1]] += dy / d * f; + }); + for (var m = 0; m < N; m++) { + fx[m] += (W / 2 - px[m]) * 0.012; + fy[m] += (H / 2 - py[m]) * 0.012; + px[m] += Math.max(-14, Math.min(14, fx[m])) * cool; + py[m] += Math.max(-14, Math.min(14, fy[m])) * cool; + } + } + // 視窗貼合 + var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9; + for (var q = 0; q < N; q++) { + minX = Math.min(minX, px[q]); maxX = Math.max(maxX, px[q]); + minY = Math.min(minY, py[q]); maxY = Math.max(maxY, py[q]); + } + var PAD = 70; + var vb = (minX - PAD) + ' ' + (minY - PAD) + ' ' + (maxX - minX + PAD * 2) + ' ' + (maxY - minY + PAD * 2); + var maxDeg = 1; + nodes.forEach(function (n) { maxDeg = Math.max(maxDeg, n.degree || 1); }); + var svg = ''; + el.forEach(function (e) { + svg += '' + + esc(nodes[e[0]].name + ' —' + (e[2] || '關聯') + '→ ' + nodes[e[1]].name) + ''; + }); + nodes.forEach(function (n, i2) { + var deg = n.degree || 1; + var r = 9 + Math.sqrt(deg) * 5; + var hub = deg >= Math.max(3, maxDeg * 0.6) ? ' hub' : ''; + svg += '' + + '' + esc(n.name + '・' + deg + ' 條關聯') + '' + + '' + esc(n.name.length > 14 ? n.name.slice(0, 14) + '…' : n.name) + ''; + }); + svg += ''; + $('map-box').innerHTML = '
' + svg + + '
╌ 知識庫總圖(點節點看鄰居)
'; + } + document.addEventListener('click', function (ev) { + var t = ev.target.closest('[data-mapnode]'); + if (!t) return; + jumpToGraph(t.getAttribute('data-mapnode')); + }); + // ── 工作流(唯讀)── function loadWorkflows() { $('wf-list').innerHTML = '
載入中…
'; diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index b5836dd..0dd975d 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -393,6 +393,15 @@ describe('filterDeprecatedEntries(Arcrun#46 搜尋殘影治標)', () => { it('空陣列 → 空陣列', () => { expect(filterDeprecatedEntries([])).toEqual([]); }); + it('濾內部型別 value/workflow(無標題雜項列;leo 2026-07-18 客戶測試回饋);block/wiki 保留', () => { + const keepBlock = { entry_type: 'block', metadata_json: '{}', content: '正常 block' }; + const keepWiki = { entry_type: 'wiki', metadata_json: '{}', content: '精耕頁' }; + const keepNoType = { metadata_json: '{}', content: '無 entry_type 不誤殺' }; + const dropValue = { entry_type: 'value', metadata_json: '{}', content: '特休假' }; + const dropWorkflow = { entry_type: 'workflow', metadata_json: '{}', content: '同步問答:…' }; + const out = filterDeprecatedEntries([keepBlock, dropValue, keepWiki, dropWorkflow, keepNoType]); + expect(out).toEqual([keepBlock, keepWiki, keepNoType]); + }); }); describe('mapGraphWorkflowOutput(#57 workflow 輸出 → plugin 形狀)', () => { diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index ef9b000..ad4a0cd 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -151,6 +151,16 @@ esc());未設 var 行為一字不變(Mira 零影響)。驗證:tsc 0、portal-auth/portal-data 測試 48/48 綠、渲染後 client JS new Function 語法檢查過。部署:uncle6 重部+設 var(leo 閘)。 +- [x] **搜尋濾內部型別+總圖頁(2026-07-18 晚,任務層小改,分支 `feat/portal-library-overview`; + 來源=leo 客戶測試回饋「無標題雜項列」+「書庫總圖」指示(原案=Arcrun#39 藏書地圖的 GUI 切片))**: + ① `filterDeprecatedEntries` 加內部型別過濾(value=slot 值外漏雜項列、workflow=工作流定義), + #46 上游修好後隨整段拔掉;測試補一案(49/49 綠)。 + ② 新增 `GET /portal/data/graph/overview`(全租戶 active 三元組 → nodes/edges,D-4 graph + 粗閘同 neighbors,kbdbFetch 直讀不經 plugin,邊數上限 500 誠實截斷)+portal「總圖」頁 + (nav 隨 graph_allowed 顯示;手刻力導向佈局、固定種子確定性、紙感樣式、點節點跳圖譜搜尋; + 頁尾連 00-MAP.md=#39「人機共用同一份地圖」的文字版)。 + ③ #39 本體(library_map Template/ingest 重算/MCP instructions+get_map)不在本次範圍,仍歸 #39 SDD。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)