Merge pull request 'portal: 搜尋濾內部型別雜項列+總圖頁(Arcrun#39 藏書地圖 GUI 切片)' (#63) from feat/portal-library-overview into main

This commit was merged in pull request #63.
This commit is contained in:
Leo
2026-07-18 10:18:18 +00:00
6 changed files with 247 additions and 9 deletions
+2
View File
@@ -2,6 +2,8 @@
**讓 AI 用的工作流軟體(目前只支援 Claude Code**
> 想先看用它做出來的產品?**[Arcrun RAG](https://git.uncle6.me/Leo/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫),有[線上 demo](https://rag-demo.arcrun.dev/portal) 可直接玩。
AI 很會寫程式,就要除錯,過程浪費很多 Token 及時間,但絕大部分是重複內容,例如登入認證、存取資料庫等。
既然做的差不多,何不預先寫好?AI 只要把除錯完成的「零件」粘起來,只除錯粘膠程式部分,就省時、省錢。
+54 -2
View File
@@ -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
* 內部型別 entryvalueslot 值外漏的無標題雜項列、workflow=工作流定義——都是系統內部件,
* 不是給搜尋用戶看的內容;2026-07-18 leo 客戶測試回饋「搜尋結果偶見無標題雜項列」)。
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
* 純函式(單測用 export)。
*/
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null }>(
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
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<string, unknown> }[] }
| null;
const records = body && Array.isArray(body.records) ? body.records : [];
const EDGE_CAP = 500;
const seen = new Set<string>();
const edges: { subject: string; predicate: string; object: string }[] = [];
const degree = new Map<string, number>();
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 多拿任何權限。
+146 -2
View File
@@ -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 {
<div id="sidenav">
<div class="logo">${brand}</div>
<div class="nav" data-nav="search">搜尋</div>
<div class="nav hide" id="nav-map" data-nav="map">總圖</div>
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
<div class="nav hide" id="nav-admin" data-nav="admin">管理</div>
@@ -258,6 +268,19 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
</div>
</div>
<!-- 總圖(Arcrun#39 藏書地圖 GUI 切片;graph 粗閘後可見):全庫三元組一張網。
力導向佈局內聯手刻(demo 量級 ≤ 數百節點),不引外部套件(頁面自足原則)。-->
<div class="view page" id="v-map">
<div class="pagehead"><span class="t">總圖</span><span class="m" id="map-meta"></span></div>
<div style="padding-top:20px">
<div id="map-box"></div>
<div style="margin-top:12px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">
點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版
(給 AI 注入用的總庫目錄)<span id="map-md-link"></span>。
</div>
</div>
</div>
<!-- 卡片詳頁 -->
<div class="view page" id="v-card">
<div class="pagehead" style="justify-content:flex-start;gap:14px">
@@ -366,6 +389,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
<!-- 手機底部導覽 -->
<div id="tabbar">
<div class="tab" data-nav="search">搜尋</div>
<div class="tab hide" id="tab-map" data-nav="map">總圖</div>
<div class="tab hide" id="tab-upload" data-nav="upload">上傳</div>
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
<div class="tab hide" id="tab-admin" data-nav="admin">管理</div>
@@ -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 = '<div class="err">請求失敗:' + esc(friendlyErr(er)) + '</div>'; });
}
// ── 總圖(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 = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
$('map-md-link').innerHTML = SOURCE_WEB_BASE
? '<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
: '存在知識庫的 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 = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + '')) + '</div>'; 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 = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
renderMap(nodes, edges);
})
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
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 || '']);
});
// 固定種子 PRNGmulberry32):確定性佈局
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 = '<svg viewBox="' + vb + '" preserveAspectRatio="xMidYMid meet">';
el.forEach(function (e) {
svg += '<line class="mapedge" x1="' + px[e[0]].toFixed(1) + '" y1="' + py[e[0]].toFixed(1) +
'" x2="' + px[e[1]].toFixed(1) + '" y2="' + py[e[1]].toFixed(1) + '"><title>' +
esc(nodes[e[0]].name + ' —' + (e[2] || '關聯') + '→ ' + nodes[e[1]].name) + '</title></line>';
});
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 += '<g class="mapnode' + hub + '" data-mapnode="' + esc(n.name) + '">' +
'<circle cx="' + px[i2].toFixed(1) + '" cy="' + py[i2].toFixed(1) + '" r="' + r.toFixed(1) + '"><title>' + esc(n.name + '・' + deg + ' 條關聯') + '</title></circle>' +
'<text x="' + px[i2].toFixed(1) + '" y="' + (py[i2] + r + 15).toFixed(1) + '" text-anchor="middle">' + esc(n.name.length > 14 ? n.name.slice(0, 14) + '…' : n.name) + '</text></g>';
});
svg += '</svg>';
$('map-box').innerHTML = '<div class="mapbox">' + svg +
'<div style="position:absolute;right:12px;bottom:10px;font-size:11.5px;color:rgba(var(--ink-rgb),.35)">╌ 知識庫總圖(點節點看鄰居)</div></div>';
}
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 = '<div class="muted">載入中…</div>';
@@ -393,6 +393,15 @@ describe('filterDeprecatedEntriesArcrun#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 形狀)', () => {
+26 -5
View File
@@ -181,10 +181,31 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`)
.bind(tpl.id, cap)
.all<{ record_id: string }>();
const out: RecordResult[] = [];
for (const { record_id } of res.results ?? []) {
const rec = await getRecord(db, record_id);
if (rec) out.push(rec);
// 批次撈齊所有 record 的 slot 值(2026-07-18 修 N+1:原本逐筆 getRecord=每筆 1 次 D1
// 往返,100 筆 triplet ≈ 19 秒——graph/總圖/rag_chat 的「查詢 20-30 秒」病根就是這裡)。
// D1 綁定參數上限 100 → id 以 90 一組分批 IN 查詢;輸出保持原排序(created_at DESC)。
const ids = (res.results ?? []).map((r) => r.record_id);
if (ids.length === 0) return [];
const byId = new Map<string, RecordResult>();
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const placeholders = chunk.map(() => '?').join(',');
const evRes = await db
.prepare(
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id IN (${placeholders})`,
)
.bind(...chunk)
.all<{ record_id: string; slot: string; content: string; template_id: string }>();
for (const r of evRes.results ?? []) {
let rec = byId.get(r.record_id);
if (!rec) {
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
byId.set(r.record_id, rec);
}
rec.values[r.slot] = r.content;
}
}
return out;
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
}
@@ -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/edgesD-4 graph
粗閘同 neighborskbdbFetch 直讀不經 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 §9PR#15 擴充,只動 `mcp/`