diff --git a/README.md b/README.md index 0ee58c0..0fae951 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Served via jsDelivr; fetched automatically during install — you never need to - `arcrun-wait/` — **arcrun-wait**(首裝) - `daemon/` — 桌面 App(Mac/Windows)安裝檔 -Built from `Arcrun@ad60863a8017` by `installer/scripts/ship.mjs`(arcrun-rag repo,release 1.4.58,built 2026-08-27)。 +Built from `Arcrun@f87260b1234f` by `installer/scripts/ship.mjs`(arcrun-rag repo,release 1.4.59,built 2026-08-27)。 ⚠️ 這份檔案由出貨管線每次自動重寫(`installer/scripts/render-bundles-readme.mjs`)—— 不要手動改這裡列的零件清單——它是算出來的:公庫=Arcrun 這一版編了什麼, diff --git a/arcrun-cypher-executor/worker.mjs b/arcrun-cypher-executor/worker.mjs index 560291b..5181faa 100644 --- a/arcrun-cypher-executor/worker.mjs +++ b/arcrun-cypher-executor/worker.mjs @@ -3195,15 +3195,6 @@ function kbdbBase(env) { function tenant(c) { return c.req.header("X-Arcrun-API-Key") ?? null; } -function graphBase(env) { - if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, ""); - return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`; -} -function graphHeaders(env) { - const headers = {}; - if (env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`; - return headers; -} var kbdbProxyRouter, NEED_KEY; var init_kbdb_proxy = __esm({ "cypher-executor/src/routes/kbdb-proxy.ts"() { @@ -3359,14 +3350,23 @@ var init_kbdb_proxy = __esm({ return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } }); }); kbdbProxyRouter.get("/kbdb/graph/neighbors/:name", async (c) => { - if (!tenant(c)) return c.json(NEED_KEY, 401); - const base = graphBase(c.env); - const headers = graphHeaders(c.env); + const owner = tenant(c); + if (!owner) return c.json(NEED_KEY, 401); + const { base, headers } = kbdbBase(c.env); + const params = new URLSearchParams(); + params.set("owner_id", owner); + for (const k of ["depth", "template", "directed"]) { + const v = c.req.query(k); + if (v) params.set(k, v); + } try { - const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param("name"))}`, { headers }); + const res = await fetch( + `${base}/graph/neighbors/${encodeURIComponent(c.req.param("name"))}?${params.toString()}`, + { headers } + ); return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } }); } catch (e) { - return c.json({ error: `kbdb-graph-plugin \u4E0D\u53EF\u9054\uFF08${base}\uFF09\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502); + return c.json({ error: `KBDB \u4E0D\u53EF\u9054\uFF08${base}\uFF09\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502); } }); kbdbProxyRouter.get("/kbdb/map", async (c) => { @@ -15047,7 +15047,6 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => { const tenant2 = knowledgeOwner(c.env); const now2 = Date.now(); const { base: kbdbUrl, headers: kbdbHeaders2 } = kbdbBase(c.env); - const graphUrl = graphBase(c.env); const [ beatEntries, taskEntries, @@ -15056,7 +15055,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => { giteaSprint, kbdbHealth, embedStatus, - graphStats, + graphProbe, tripletTotal, entriesTotal, wikiCardTotal, @@ -15069,11 +15068,15 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => { cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)), fetchJson(`${kbdbUrl}/health`, kbdbHeaders2), fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders2), - // graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。 - // headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。 + // 圖服務活著沒(燈號)——數字不從這裡拿,見 fetchTripletTotal。 + // 🔴 inkstone/Arcrun#168 收斂:原本探的是 kbdb-graph-plugin 的 /triplets/stats, + // 但圖能力已收斂回 KBDB(GET /graph/neighbors/:node)⇒ 探那顆 plugin 等於在量一個 + // 沒有人走的服務:它沒裝就永遠紅燈、裝了也只證明一個不再被使用的東西活著。 + // 改探 KBDB 那支端點本身——用一個不存在的節點名,它會誠實回 count:0 的 200 + //(graph-query.ts:查不到就是空結果,不是錯誤),正好當「這條路通不通」的探針。 fetchJson( - `${graphUrl}/triplets/stats`, - graphHeaders(c.env) + `${kbdbUrl}/graph/neighbors/${encodeURIComponent("__arcrun_graph_probe__")}?depth=1`, + kbdbHeaders2 ), fetchTripletTotal(c.env, tenant2), // owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732) @@ -15183,8 +15186,8 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => { system: { kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false, embed: embedStatus ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } : null, - // ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關) - graph: { ok: graphStats !== null, triplets: tripletTotal }, + // ok = 圖查詢通不通(探針讀得到就是通);triplets = KBDB 真 COUNT(兩件事,不互相吞) + graph: { ok: graphProbe !== null, triplets: tripletTotal }, workflow_total: workflowTotal }, kb: { @@ -15270,7 +15273,6 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => { // cypher-executor/src/routes/portal-data.ts init_dist(); -init_kbdb_proxy(); init_webhook_handlers(); // cypher-executor/src/lib/app-system.ts @@ -17001,7 +17003,7 @@ function unwrapWorkflowData(data, key) { } return outer; } -function mapGraphWorkflowOutput(data) { +function mapGraphNeighborsResponse(data) { const layer = unwrapWorkflowData(data, "neighbors"); const neighbors = Array.isArray(layer.neighbors) ? layer.neighbors : []; const edges = Array.isArray(layer.edges) ? layer.edges : []; @@ -17168,6 +17170,14 @@ portalDataRouter.get( return c.json({ success: true, entry }); }) ); +async function fetchNeighborsFromKbdb(env, tenant2, node, depth) { + const qs = new URLSearchParams(); + qs.set("depth", String(depth)); + qs.set("template", "triplet"); + const res = await kbdbFetch(env, `/graph/neighbors/${encodeURIComponent(node)}?${qs.toString()}&${ownerQuery(tenant2)}`); + const body = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, body }; +} portalDataRouter.get( "/portal/data/graph/neighbors/:name", (c) => run(c, async () => { @@ -17177,50 +17187,41 @@ portalDataRouter.get( if (!await hasGraphAccess(c.env, libraries)) { return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403); } - const nodeName = normalizeCjkQuery(c.req.param("name")); const tenant2 = knowledgeOwner(c.env); - const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors"); - if (wfGraph) { - const depthRaw = c.req.query("depth") ?? ""; - const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2; - const result = await executeWebhookGraph( - c.env, - wfGraph, - // t116: 補傳 kbdb_base;t128: 補傳 template(workflow fetch_triplets.url 用 {{input.template}}) - { node: nodeName, depth, namespace: tenant2, owner: tenant2, kbdb_base: c.env.KBDB_BASE_URL ?? "", template: "triplet" }, - "graph_neighbors", - tenant2, - c.executionCtx - ); - if (!result.success) { - return c.json({ error: `graph_neighbors workflow \u57F7\u884C\u5931\u6557\uFF1A${result.error ?? "\u672A\u77E5\u932F\u8AA4"}` }, 502); - } - return c.json(mapGraphWorkflowOutput(result.data)); - } - const base = graphBase(c.env); - const headers = graphHeaders(c.env); + const rawName = c.req.param("name"); + const depthRaw = c.req.query("depth") ?? ""; + const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2; + const tryNames = [rawName]; + const normalized = normalizeCjkQuery(rawName); + if (normalized !== rawName) tryNames.push(normalized); + let first = null; try { - const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers }); - if (!res.ok) { - return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } }); + for (const name of tryNames) { + const r = await fetchNeighborsFromKbdb(c.env, tenant2, name, depth); + if (!first) first = r; + if (!r.ok) break; + const mapped = mapGraphNeighborsResponse(r.body); + if (mapped.count > 0) return c.json(mapped); } - const resText = await res.text().catch(() => ""); - let data = null; - try { - data = JSON.parse(resText); - } catch { - } - if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 && Array.isArray(data.edges) && data.edges.length === 0) { - const fallbackName = await fuzzyFindNode(c.env, tenant2, nodeName); - if (fallbackName && fallbackName !== nodeName) { - const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers }); - return new Response(res2.body, { status: res2.status, headers: { "Content-Type": "application/json" } }); + if (first?.ok) { + const fallbackName = await fuzzyFindNode(c.env, tenant2, rawName); + if (fallbackName && !tryNames.includes(fallbackName)) { + const r = await fetchNeighborsFromKbdb(c.env, tenant2, fallbackName, depth); + if (r.ok) { + const mapped = mapGraphNeighborsResponse(r.body); + if (mapped.count > 0) return c.json(mapped); + } } } - return new Response(resText, { status: res.status, headers: { "Content-Type": "application/json" } }); } catch (e) { - return c.json({ error: `kbdb-graph-plugin \u4E0D\u53EF\u9054\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502); + return c.json({ error: `KBDB \u5716\u8B5C\u67E5\u8A62\u4E0D\u53EF\u9054\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502); } + if (!first) return c.json({ error: "KBDB \u5716\u8B5C\u67E5\u8A62\u6C92\u6709\u56DE\u61C9" }, 502); + if (!first.ok) { + const err = first.body && typeof first.body === "object" ? first.body : { error: `KBDB \u5716\u8B5C\u67E5\u8A62\u5931\u6557\uFF08HTTP ${first.status}\uFF09` }; + return c.json(err, first.status); + } + return c.json(mapGraphNeighborsResponse(first.body)); }) ); portalDataRouter.get( @@ -17538,6 +17539,29 @@ function canReadRecord(rec, tenant2, libraries) { const lib = recordLibrary(rec.values); return lib === null || canReadLibrary(libraries, lib); } +function cardOriginalLocation(entries, library, libraryRoot) { + for (const e of entries) { + if (typeof e.metadata_json !== "string" || !e.metadata_json) continue; + let meta; + try { + meta = JSON.parse(e.metadata_json); + } catch { + continue; + } + const sourcePath = typeof meta.source_path === "string" && meta.source_path.trim() ? meta.source_path.trim() : null; + const machine = typeof meta.machine_label === "string" && meta.machine_label.trim() ? meta.machine_label.trim() : typeof meta.machine === "string" && meta.machine.trim() ? meta.machine.trim() : null; + if (!sourcePath && !machine) continue; + const root = libraryRoot && libraryRoot.trim() ? libraryRoot.trim() : null; + return { + machine, + library, + library_root: root, + source_path: sourcePath, + full_path: root && sourcePath ? `${root.replace(/\/+$/, "")}/${sourcePath.replace(/^\/+/, "")}` : null + }; + } + return null; +} portalDataRouter.get( "/portal/data/library-cards", (c) => run(c, async () => { @@ -17579,7 +17603,19 @@ portalDataRouter.get( } const entries = filterDeprecatedEntries(body.entries); if (entries.length === 0) return notFound(c); - return c.json({ success: true, library, page_name: pageName, entries, count: entries.length }); + const libRecords = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE).catch(() => []); + const libRootRaw = libRecords.find((r) => String(r.values.name ?? "") === library)?.values.root; + const libraryRoot = typeof libRootRaw === "string" ? libRootRaw : null; + const location = cardOriginalLocation(entries, library, libraryRoot); + return c.json({ + success: true, + library, + page_name: pageName, + entries, + count: entries.length, + location, + ...location ? {} : { location_hint: "\u9019\u5F35\u5361\u7684\u6BB5\u843D\u6C92\u6709 machine/source_path \u4E2D\u7E7C\u8CC7\u6599\uFF0C\u7B54\u4E0D\u51FA\u539F\u6587\u7684\u5BE6\u9AD4\u4F4D\u7F6E" } + }); }) ); portalDataRouter.get( @@ -17711,6 +17747,19 @@ portalDataRouter.get( if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) return c.json({ success: true, records: [], count: 0, total: 0 }); + const template = c.req.param("template"); + if (template === LIBRARY_TEMPLATE) { + const all = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + const records2 = libraries.includes("*") ? all : all.filter((r) => libraries.includes(String(r.values.name ?? ""))); + return c.json({ + success: true, + records: records2, + count: records2.length, + page_size: all.length, + filtered_out: all.length - records2.length, + total: records2.length + }); + } const tenant2 = knowledgeOwner(c.env); const params = new URLSearchParams(ownerQuery(tenant2)); for (const k of ["limit", "offset"]) { diff --git a/arcrun-kbdb/worker.mjs b/arcrun-kbdb/worker.mjs index 26a9748..eb2ac5b 100644 --- a/arcrun-kbdb/worker.mjs +++ b/arcrun-kbdb/worker.mjs @@ -4241,11 +4241,15 @@ async function selectLibrariesForQuestion(db, question, opts = {}) { SELECT object AS name, ${libraryOf("library")} AS library FROM act WHERE object IS NOT NULL) SELECT name, library, COUNT(*) AS degree FROM ent - WHERE LENGTH(name) >= 2 AND instr(?, lower(name)) > 0 + WHERE LENGTH(name) >= 2 + AND ( + instr(?, lower(name)) > 0 + OR (LENGTH(?) >= 2 AND instr(lower(name), ?) > 0) + ) GROUP BY name, library ORDER BY degree DESC, name ASC LIMIT 200` - ).bind(...params, qNorm).all(); + ).bind(...params, qNorm, qNorm, qNorm).all(); for (const r of res.results ?? []) { const cur = hits.get(r.library) ?? { entities: /* @__PURE__ */ new Set(), score: 0 }; cur.entities.add(r.name); @@ -4545,6 +4549,109 @@ mapRoutes.get("/:library", async (c) => { return c.json({ success: true, map }); }); +// kbdb/src/actions/graph-query.ts +async function findTripletEdgesByNode(db, templateIdOrName, fields, nodeValue, owner_id) { + if (!nodeValue || fields.length === 0) return []; + const tpl = await getTemplate(db, templateIdOrName); + if (!tpl) return []; + const valueSql = owner_id ? `SELECT id FROM entries WHERE content = ? AND entry_type = 'value' AND (owner_id = ? OR owner_id IS NULL)` : `SELECT id FROM entries WHERE content = ? AND entry_type = 'value'`; + const valueParams = owner_id ? [nodeValue, owner_id] : [nodeValue]; + const valueRows = await db.prepare(valueSql).bind(...valueParams).all(); + const valueIds = (valueRows.results ?? []).map((r) => r.id); + if (valueIds.length === 0) return []; + const fieldIds = fields.map((f) => fieldEntryId(tpl.id, f)); + const dstPh = valueIds.map(() => "?").join(","); + const relPh = fieldIds.map(() => "?").join(","); + const relSql = owner_id ? `SELECT DISTINCT src_id AS record_id FROM entries + WHERE dst_id IN (${dstPh}) AND rel_id IN (${relPh}) AND owner_id = ?` : `SELECT DISTINCT src_id AS record_id FROM entries + WHERE dst_id IN (${dstPh}) AND rel_id IN (${relPh})`; + const relParams = owner_id ? [...valueIds, ...fieldIds, owner_id] : [...valueIds, ...fieldIds]; + const relRows = await db.prepare(relSql).bind(...relParams).all(); + const recordIds = (relRows.results ?? []).map((r) => r.record_id); + if (recordIds.length === 0) return []; + const recPh = recordIds.map(() => "?").join(","); + const pivotSql = ` + SELECT b.src_id AS record_id, + MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS subject, + MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS predicate, + MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS object, + MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS status + FROM entries b + LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs' + LEFT JOIN entries v ON v.id = r.dst_id + WHERE b.rel_id = 'sys_belongs' AND b.src_id IN (${recPh}) + GROUP BY b.src_id`; + const pivotParams = [ + fieldEntryId(tpl.id, "subject"), + fieldEntryId(tpl.id, "predicate"), + fieldEntryId(tpl.id, "object"), + fieldEntryId(tpl.id, "status"), + ...recordIds + ]; + const pivotRows = await db.prepare(pivotSql).bind(...pivotParams).all(); + return pivotRows.results ?? []; +} +async function graphNeighbors(db, start, opts = {}) { + const depth = Math.max(1, Math.min(Math.floor(opts.depth ?? 1) || 1, 10)); + const template = opts.template ?? "triplet"; + const directed = !!opts.directed; + const owner_id = opts.owner_id; + const visited = /* @__PURE__ */ new Set([start]); + let frontier = [start]; + const neighbors = []; + for (let d = 1; d <= depth; d++) { + if (frontier.length === 0) break; + const next = []; + for (const cur of frontier) { + const outgoing = await findTripletEdgesByNode(db, template, ["subject"], cur, owner_id); + for (const e of outgoing) { + if (e.status === "deprecated") continue; + const nb = e.object; + if (!nb || visited.has(nb)) continue; + visited.add(nb); + neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d }); + next.push(nb); + } + if (!directed) { + const incoming = await findTripletEdgesByNode(db, template, ["object"], cur, owner_id); + for (const e of incoming) { + if (e.status === "deprecated") continue; + const nb = e.subject; + if (!nb || visited.has(nb)) continue; + visited.add(nb); + neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d }); + next.push(nb); + } + } + } + frontier = next; + } + return { success: true, start, depth, directed, neighbors, count: neighbors.length }; +} + +// kbdb/src/routes/graph.ts +var graphRoutes = new Hono2(); +graphRoutes.get("/neighbors/:node", async (c) => { + const node = c.req.param("node"); + if (!node) return c.json({ success: false, error: "node required" }, 400); + const depthRaw = Number(c.req.query("depth")); + const depth = Number.isFinite(depthRaw) && depthRaw > 0 ? Math.floor(depthRaw) : void 0; + const directed = c.req.query("directed") === "true"; + const template = c.req.query("template") || void 0; + const owner_id = c.req.query("owner_id") || void 0; + try { + const result = await graphNeighbors(c.env.DB, node, { depth, template, directed, owner_id }); + const edges = result.neighbors.map((n) => ({ + subject: n.from, + predicate: n.predicate, + object: n.node + })); + return c.json({ ...result, edges }); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 500); + } +}); + // kbdb/src/actions/execution-log.ts var SUCCESS_MESSAGE_MAX = 200; var FAILED_MESSAGE_MAX = 2e3; @@ -4832,6 +4939,12 @@ var GENERATIONS = [ // 🪦 entry_values 2026-08-15 廢除;「看到它=那台沒跟上,不是它還合法」。 { kind: "no_table", name: "entry_values" } ] + }, + { + n: 8, + file: "0008_entries_content_index.sql", + what: "entries.content \u7D22\u5F15\u2014\u2014graph \u9130\u5C45\u67E5\u8A62\u5F9E\u7BC0\u9EDE\u540D\u76F4\u63A5\u67E5\uFF08\u4E0D\u6488\u5168\u8868\uFF09\uFF0CArcrun#168 \u6839\u56E0\u4FEE\u5FA9\u7684\u5730\u57FA", + checks: [{ kind: "index", name: "idx_entries_content" }] } ]; var EXPECTED_GENERATION = GENERATIONS[GENERATIONS.length - 1].n; @@ -4990,6 +5103,7 @@ app.route("/recipe-stats", recipeStatRoutes); app.route("/execution-log", executionLogRoutes); app.route("/embed", embedRoutes); app.route("/map", mapRoutes); +app.route("/graph", graphRoutes); var index_default = app; export { index_default as default diff --git a/arcrun-mcp/worker.mjs b/arcrun-mcp/worker.mjs index 3102b6c..bfc0ec6 100644 --- a/arcrun-mcp/worker.mjs +++ b/arcrun-mcp/worker.mjs @@ -32025,7 +32025,7 @@ function registerAllKbdbGraphTools(server, env, orgNamespace, identity) { function registerGraphNeighbors(server, env, orgNamespace, identity) { server.tool( "kbdb_graph_neighbors", - "knowledge graph \u9130\u5C45\u67E5\u8A62\uFF081-hop/N-hop \u95DC\u4FC2\u904D\u6B77\uFF09\uFF1A\u7D66\u4E00\u500B\u7BC0\u9EDE\u540D\uFF0C\u6CBF KBDB triplet\uFF08subject-predicate-object\uFF09\u8A18\u9304\u505A BFS\uFF0C\u56DE\u50B3 depth \u8DF3\u5167\u7684\u9130\u5C45\u6E05\u55AE\uFF08[{node, predicate, from, depth}]\uFF09\u3002\u8207 kbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\u4E92\u88DC\uFF1A\u627E\u300C\u8DDF X \u6709\u95DC\u4FC2\u7684\u6771\u897F\u300D\u7528\u672C\u5DE5\u5177\uFF0C\u627E\u300C\u5167\u5BB9\u542B\u95DC\u9375\u5B57\u7684\u6771\u897F\u300D\u7528 kbdb_search\u3002\u9700\u8981 namespace \u88E1\u5DF2\u90E8\u7F72 graph_neighbors workflow\uFF08\u6C92\u88DD\u6703\u56DE\u5B89\u88DD\u6307\u5F15\uFF0C\u4E0D\u6703 crash\uFF09\u3002", + "knowledge graph \u9130\u5C45\u67E5\u8A62\uFF081-hop/N-hop \u95DC\u4FC2\u904D\u6B77\uFF09\uFF1A\u7D66\u4E00\u500B\u7BC0\u9EDE\u540D\uFF0C\u6CBF KBDB triplet\uFF08subject-predicate-object\uFF09\u8A18\u9304\u505A BFS\uFF0C\u56DE\u50B3 depth \u8DF3\u5167\u7684\u9130\u5C45\u6E05\u55AE\uFF08[{node, predicate, from, depth}]\uFF09\u3002\u8207 kbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\u4E92\u88DC\uFF1A\u627E\u300C\u8DDF X \u6709\u95DC\u4FC2\u7684\u6771\u897F\u300D\u7528\u672C\u5DE5\u5177\uFF0C\u627E\u300C\u5167\u5BB9\u542B\u95DC\u9375\u5B57\u7684\u6771\u897F\u300D\u7528 kbdb_search\u3002\u767B\u5165\u8EAB\u5206\uFF08\u5E33\u5BC6\uFF0FOAuth\uFF09\u76F4\u63A5\u8D70 KBDB \u7684\u5716 API\uFF0C\u4E0D\u9700\u8981\u88DD\u4EFB\u4F55 workflow\uFF1B\u670D\u52D9\u7D1A token \u9023\u7DDA\u624D\u9700\u8981 namespace \u88E1\u5DF2\u90E8\u7F72 graph_neighbors workflow\uFF08\u6C92\u88DD\u6703\u56DE\u5B89\u88DD\u6307\u5F15\uFF0C\u4E0D\u6703 crash\uFF09\u3002", { subject: external_exports.string().min(1).describe( "\u8D77\u9EDE\u7BC0\u9EDE\u540D\uFF08graph triplet \u7684 subject/object \u503C\uFF09\uFF0C\u5982 'Arcrun'" @@ -32331,6 +32331,20 @@ function registerGetMap(server, env, identity) { // mcp/src/tools/kbdb_index.ts var INDEX_CARD_NAME = "00-INDEX"; +function locationHints(location, hint) { + if (location) { + const where = [ + location.machine ? `\u8A2D\u5099\u300C${location.machine}\u300D` : null, + `\u5EAB\u300C${location.library}\u300D`, + location.full_path ? `\u8DEF\u5F91 ${location.full_path}` : location.source_path ? `\u5EAB\u5167\u8DEF\u5F91 ${location.source_path}\uFF08\u9019\u500B\u5EAB\u9084\u6C92\u767B\u8A18\u5BE6\u9AD4\u8CC7\u6599\u593E\uFF0C\u7B54\u4E0D\u51FA\u5B8C\u6574\u8DEF\u5F91\uFF09` : null + ].filter(Boolean); + return [ + `\u539F\u6587\u5728\u54EA\uFF1A${where.join("\u3001")}\u3002`, + "\u{1F534} \u56DE\u7B54\u300C\u539F\u6587\u5728\u54EA\u300D\u6642\u7528\u9019\u500B location \u6B04\u4F4D\uFF0C\u4E0D\u8981\u5F15\u7528\u5361\u7247\u5167\u6587\u88E1\u300C### \u51FA\u8655\u300D\u90A3\u4E00\u6BB5\u2014\u2014\u90A3\u5BEB\u7684\u662F\u5167\u90E8\u76F8\u5C0D\u8DEF\u5F91\uFF08`../\u6A94\u540D`\uFF09\uFF0C\u4F7F\u7528\u8005\u62FF\u8457\u5B83\u8D70\u4E0D\u5230\u4EFB\u4F55\u5730\u65B9\u3002" + ]; + } + return [hint ?? "\u9019\u5F35\u5361\u6C92\u6709 machine/source_path \u4E2D\u7E7C\u8CC7\u6599\uFF0C\u7B54\u4E0D\u51FA\u539F\u6587\u7684\u5BE6\u9AD4\u4F4D\u7F6E\u2014\u2014\u4E0D\u8981\u7528\u5167\u6587\u300C### \u51FA\u8655\u300D\u90A3\u6BB5\u4EE3\u7B54\u3002"]; +} function anchorOf(block) { try { const meta = typeof block.metadata_json === "string" ? JSON.parse(block.metadata_json) : block.metadata_json ?? {}; @@ -32464,11 +32478,13 @@ function registerGetCard(server, env, identity) { ); } const content = assembleCard(blocks); + const location = isPortal ? body.location ?? null : null; return successResponse( - { library, page_name, content, block_count: blocks.length }, + { library, page_name, content, block_count: blocks.length, location }, [ `\u9019\u662F\u300C${page_name}\u300D\u7684\u5B8C\u6574\u5167\u5BB9\uFF08${blocks.length} \u6BB5\uFF0C\u5DF2\u7167\u539F\u7A3F\u9806\u5E8F\u7D44\u597D\uFF09`, - "\u8981\u770B\u5B83\u9023\u5230\u54EA\u4E9B\u5361\uFF1Akbdb_graph_neighbors(name)" + "\u8981\u770B\u5B83\u9023\u5230\u54EA\u4E9B\u5361\uFF1Akbdb_graph_neighbors(name)", + ...locationHints(location, isPortal ? body.location_hint : void 0) ] ); } catch (e) { @@ -32567,6 +32583,11 @@ var KNOWLEDGE_FIRST = [ "3. **\u628A\u90A3\u5F35\u5361\u6574\u5F35\u8B80\u5B8C** \u2192 `kbdb_get_card({ library, page_name })`", " \u6458\u8981\uFF0B\u91CD\u9EDE\uFF0B\u5BE6\u9AD4\uFF0B\u95DC\u806F\u4E00\u6B21\u5230\u624B\uFF0C\u7167\u539F\u7A3F\u9806\u5E8F\u7D44\u597D\u3002**\u5F15\u7528\u5167\u5BB9\u6642\u4E00\u5F8B\u7528\u9019\u4E00\u652F\u3002**", "", + "\u{1F534} **\u6709\u4EBA\u554F\u300C\u539F\u6587\u5728\u54EA\uFF0F\u9019\u4EFD\u8CC7\u6599\u54EA\u4F86\u7684\u300D\u2014\u2014\u7B54\u6848\u8981\u8B80 `kbdb_get_card` \u56DE\u61C9\u7684 `location` \u6B04\u4F4D", + "\uFF08\u8A2D\u5099\uFF0F\u5EAB\uFF0F\u5EAB\u5167\u8DEF\u5F91\uFF09\uFF0C\u4E0D\u8981\u5F15\u7528\u5361\u7247\u5167\u6587\u88E1\u300C### \u51FA\u8655\u300D\u90A3\u4E00\u6BB5\u3002** \u90A3\u6BB5\u5BEB\u7684\u662F\u8403\u5361\u7576\u4E0B\u7684", + "\u5167\u90E8\u76F8\u5C0D\u8DEF\u5F91\uFF08\u5F62\u5982 `../\u6A94\u540D`\uFF09\uFF0C\u4F7F\u7528\u8005\u62FF\u8457\u5B83\u8D70\u4E0D\u5230\u4EFB\u4F55\u5730\u65B9\uFF08inkstone/Arcrun#167\uFF09\u3002", + "`location` \u662F null \u6642\uFF0C`kbdb_get_card` \u7684 hints \u6703\u8AA0\u5BE6\u8B1B\u7F3A\u4EC0\u9EBC\u2014\u2014\u7167\u8457\u8AAA\uFF0C\u4E0D\u8981\u7528\u5167\u6587\u90A3\u6BB5\u4EE3\u7B54\u3002", + "", "\u70BA\u4EC0\u9EBC\u662F\u9019\u4E09\u6B65\u3001\u800C\u4E0D\u662F\u76F4\u63A5\u5168\u6587\u641C\uFF1A\u4E00\u500B\u554F\u984C\u7684\u7B54\u6848\u901A\u5E38**\u6574\u5F35\u5361**\u624D\u8AAA\u5F97\u5B8C\u6574\uFF0C", "\u800C\u641C\u5C0B\u53EA\u6703\u56DE**\u547D\u4E2D\u95DC\u9375\u5B57\u7684\u90A3\u5E7E\u6BB5**\u2014\u2014\u4F60\u53EF\u80FD\u62FF\u5230\u6458\u8981\u537B\u6C92\u62FF\u5230\u6578\u5B57\u3001\u62FF\u5230\u95DC\u806F\u537B\u6C92\u62FF\u5230\u5BE6\u9AD4\uFF0C", "\u800C\u4E14**\u4F60\u4E0D\u6703\u77E5\u9053\u81EA\u5DF1\u6F0F\u4E86\u54EA\u5E7E\u6BB5**\u3002\u76EE\u9304\u7684\u5B58\u5728\u5C31\u662F\u70BA\u4E86\u8B93\u4F60\u5148\u770B\u5230\u300C\u6709\u4EC0\u9EBC\u300D\u518D\u6C7A\u5B9A\u8B80\u4EC0\u9EBC\u3002", diff --git a/daemon/Arcrun-0.18.41.dmg b/daemon/Arcrun-0.18.41.dmg new file mode 100644 index 0000000..ffdfa99 Binary files /dev/null and b/daemon/Arcrun-0.18.41.dmg differ diff --git a/daemon/Arcrun-0.18.41.msix b/daemon/Arcrun-0.18.41.msix new file mode 100644 index 0000000..a6e6fee Binary files /dev/null and b/daemon/Arcrun-0.18.41.msix differ diff --git a/daemon/Arcrun-win-0.18.41.exe b/daemon/Arcrun-win-0.18.41.exe new file mode 100755 index 0000000..990edb6 Binary files /dev/null and b/daemon/Arcrun-win-0.18.41.exe differ diff --git a/manifest.json b/manifest.json index 426c260..e7c0e98 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "schema": 2, "built": "2026-08-27", - "source": "Arcrun@ad60863a8017", + "source": "Arcrun@f87260b1234f", "core": [ { "name": "arcrun-array-ops", @@ -198,7 +198,7 @@ "name": "arcrun-cypher-executor", "main_module": "worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs", - "js_bytes": 694462, + "js_bytes": 696639, "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -239,10 +239,10 @@ "stripped": { "services": 13 }, - "source_commit": "af6f244b44b28a16a3578cf554d1e199a170a05e", - "source_content_sha256": "531937c2ebade75fe67676071ca5f658775c11aa855359330ae19117db1d1807", - "sha256": "531937c2ebade75fe67676071ca5f658775c11aa855359330ae19117db1d1807", - "bytes": 694462 + "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", + "source_content_sha256": "2390294711ed529125559e505ed8f575093798480606253b31021b6faca4c758", + "sha256": "2390294711ed529125559e505ed8f575093798480606253b31021b6faca4c758", + "bytes": 696639 }, { "name": "arcrun-date-ops", @@ -396,7 +396,7 @@ "name": "arcrun-kbdb", "main_module": "worker.mjs", "main_file": "arcrun-kbdb/worker.mjs", - "js_bytes": 189149, + "js_bytes": 194366, "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -416,16 +416,16 @@ "ENVIRONMENT": "production" } }, - "source_commit": "af6f244b44b28a16a3578cf554d1e199a170a05e", - "source_content_sha256": "80d647b70a89f3d42398be621001ebfcdd08371b68ba523b6fa1f2048c06b64b", - "sha256": "80d647b70a89f3d42398be621001ebfcdd08371b68ba523b6fa1f2048c06b64b", - "bytes": 189149 + "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", + "source_content_sha256": "97140b9d10337c0f995b5bd5a4a3403ff06403afb6a51394b3ede013293c5bf6", + "sha256": "97140b9d10337c0f995b5bd5a4a3403ff06403afb6a51394b3ede013293c5bf6", + "bytes": 194366 }, { "name": "arcrun-mcp", "main_module": "worker.mjs", "main_file": "arcrun-mcp/worker.mjs", - "js_bytes": 1205568, + "js_bytes": 1208080, "modules": [], "compat_date": "2024-11-27", "compat_flags": [ @@ -440,10 +440,10 @@ "ai": false, "vars": {} }, - "source_commit": "af6f244b44b28a16a3578cf554d1e199a170a05e", - "source_content_sha256": "03d2022d9d3134988fddfd52bd9aa6d1c7734f9d14870fbe1cbb39d9aa27a2f4", - "sha256": "03d2022d9d3134988fddfd52bd9aa6d1c7734f9d14870fbe1cbb39d9aa27a2f4", - "bytes": 1205568 + "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", + "source_content_sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0", + "sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0", + "bytes": 1208080 }, { "name": "arcrun-merge", @@ -507,7 +507,7 @@ "name": "arcrun-rag-ui", "main_module": "index.js", "main_file": "tier2/ui/index.js", - "js_bytes": 673028, + "js_bytes": 677629, "modules": [], "compat_date": "2026-07-01", "compat_flags": [], @@ -518,10 +518,10 @@ "ai": false, "vars": {} }, - "source_commit": "790b41d10d48f8d6d117797c4b81f9884f909209", - "source_content_sha256": "8a6048f8ffe7125d0077688043a787849e8332d9613d6b693494583aef9de927", - "sha256": "8a6048f8ffe7125d0077688043a787849e8332d9613d6b693494583aef9de927", - "bytes": 673028 + "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", + "source_content_sha256": "8f3bbc90f901a0f06f40f4fdaaf5ec4ed80a2012822596a78ae47a92014a9a42", + "sha256": "8f3bbc90f901a0f06f40f4fdaaf5ec4ed80a2012822596a78ae47a92014a9a42", + "bytes": 677629 }, { "name": "arcrun-set", @@ -698,12 +698,12 @@ "bytes": 67697 } ], - "release": "1.4.58", + "release": "1.4.59", "built_for": "oauth-installer-lazy-load", "notes": [ "rag_takedown_direct 用了 __CARDS_PREFIX__,但安裝器的代換表沒有它 ⇒ 這個佔位符會原封不動被推進使用者的工作流。" ], - "fingerprint": "0216b2185da93feb5878b09abb05c72eb25978a9eeeef0e2c06639b377d67be9", + "fingerprint": "6d5575253074f913fa548e13da8ce6c9cda4d9598a8bd5971642ab66ea43bf80", "library": [ { "name": "arcrun-array-ops", @@ -888,7 +888,7 @@ "name": "arcrun-cypher-executor", "main_module": "worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs", - "js_bytes": 694462, + "js_bytes": 696639, "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -929,8 +929,8 @@ "stripped": { "services": 13 }, - "source_commit": "af6f244b44b28a16a3578cf554d1e199a170a05e", - "source_content_sha256": "531937c2ebade75fe67676071ca5f658775c11aa855359330ae19117db1d1807", + "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", + "source_content_sha256": "2390294711ed529125559e505ed8f575093798480606253b31021b6faca4c758", "first_install": true }, { @@ -1075,7 +1075,7 @@ "name": "arcrun-kbdb", "main_module": "worker.mjs", "main_file": "arcrun-kbdb/worker.mjs", - "js_bytes": 189149, + "js_bytes": 194366, "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -1095,15 +1095,15 @@ "ENVIRONMENT": "production" } }, - "source_commit": "af6f244b44b28a16a3578cf554d1e199a170a05e", - "source_content_sha256": "80d647b70a89f3d42398be621001ebfcdd08371b68ba523b6fa1f2048c06b64b", + "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", + "source_content_sha256": "97140b9d10337c0f995b5bd5a4a3403ff06403afb6a51394b3ede013293c5bf6", "first_install": true }, { "name": "arcrun-mcp", "main_module": "worker.mjs", "main_file": "arcrun-mcp/worker.mjs", - "js_bytes": 1205568, + "js_bytes": 1208080, "modules": [], "compat_date": "2024-11-27", "compat_flags": [ @@ -1118,8 +1118,8 @@ "ai": false, "vars": {} }, - "source_commit": "af6f244b44b28a16a3578cf554d1e199a170a05e", - "source_content_sha256": "03d2022d9d3134988fddfd52bd9aa6d1c7734f9d14870fbe1cbb39d9aa27a2f4", + "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", + "source_content_sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0", "first_install": true }, { @@ -1180,7 +1180,7 @@ "name": "arcrun-rag-ui", "main_module": "index.js", "main_file": "tier2/ui/index.js", - "js_bytes": 673028, + "js_bytes": 677629, "modules": [], "compat_date": "2026-07-01", "compat_flags": [], @@ -1191,8 +1191,8 @@ "ai": false, "vars": {} }, - "source_commit": "790b41d10d48f8d6d117797c4b81f9884f909209", - "source_content_sha256": "8a6048f8ffe7125d0077688043a787849e8332d9613d6b693494583aef9de927", + "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", + "source_content_sha256": "8f3bbc90f901a0f06f40f4fdaaf5ec4ed80a2012822596a78ae47a92014a9a42", "first_install": true }, { @@ -1375,13 +1375,17 @@ "worker": "arcrun-mcp", "why": "leo 2026-08-10:「今天只要清單含有 MCP 即可」。產品承諾是「把自己的 AI 接上自己的知識庫」,MCP 就是那條線;缺它的話那句承諾從第一步就不成立(prod 1.4.30 曾整包沒有它)。" }, + { + "worker": "arcrun-code", + "why": "graph_neighbors/prep 用 __CODE_URL__" + }, { "worker": "arcrun-http-request", - "why": "graph_neighbors/fetch_triplets 用 __HTTP_REQ_URL__" + "why": "graph_neighbors/fetch_neighbors 用 __HTTP_REQ_URL__" }, { "worker": "arcrun-code", - "why": "graph_neighbors/bfs_neighbors 用 __CODE_URL__" + "why": "graph_neighbors/parse_neighbors 用 __CODE_URL__" }, { "worker": "arcrun-auth-static-key", @@ -1485,20 +1489,20 @@ } ], "daemon": { - "version": "0.18.40", + "version": "0.18.41", "mac": { - "file": "daemon/Arcrun-0.18.40.dmg", - "sha256": "5434b22742c9d1b449d22ab18fe48f2bb9f57c6ac4690e824e391135e7df0ab3" + "file": "daemon/Arcrun-0.18.41.dmg", + "sha256": "c37705824a139a2b0f2f8fb7d3d5aede686296e6c7820cb96b246d5e6638af61" }, "win": { - "file": "daemon/Arcrun-win-0.18.40.exe", - "sha256": "d84c3159379fd3a415bcab5192a376ae3753f9e12dd8624c5ffced59406b5139" + "file": "daemon/Arcrun-win-0.18.41.exe", + "sha256": "873ffe08176cfcd381d1ef02a9c88f52be12a8f918eaa83d2004d59db257c19f" }, "msix": { - "file": "daemon/Arcrun-0.18.40.msix", - "sha256": "809b0da36d43ce0b75780f91e34f9f4f0138ea075f3ac92c95155a4f0df8d804" + "file": "daemon/Arcrun-0.18.41.msix", + "sha256": "6806a58b6efd570e59502f7a5c4d7307fda2fcae39162f8089bab135110c4532" }, - "built": "20260827-1401", - "notes": "卡住的檔案不再擋住後面所有人" + "built": "20260827-1950", + "notes": "不叫「docs」的文件夾,現在也認得出來" } } diff --git a/tier2/ui/index.js b/tier2/ui/index.js index 8cfdf2b..1034f9e 100644 --- a/tier2/ui/index.js +++ b/tier2/ui/index.js @@ -1,6 +1,6 @@ // arcrun-rag-ui — 用戶實例的 GUI(console-ui/public 內嵌;由 Arcrun scripts/build-ui-worker.mjs 產生,勿手改) -const FILES = {"/apple-touch-icon.png":{"type":"image/png","b64":true,"data":"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"},"/console/dashboard/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun 駕駛艙\n\n\n\n\n\n\n
\n
\n
Arcrun 駕駛艙
\n
\n
\n \n
\n
\n
\n
\n
\n
載入中
\n
\n
\n
\n
\n
\n
今日完成
\n
/
\n
\n
\n
\n
收件匣未處理
\n
\n
來自 Telegram
\n
\n
\n
\n
等你的事
\n
載入中…
\n
\n
\n
今日路線
\n \n
本週
\n \n
系統狀況live 健康信號
\n
載入中…
\n
每 60 秒自動刷新
\n 進入完整控制台 ›\n
\n\n\n\n"},"/console/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Console\n\n\n\n\n\n\n\n\n
\n
\n
\n
Arcrun
\n
私人系統・僅供擁有者進入
\n
\n
\n \n \n \n
\n
\n
這是一個人的智慧總部。
若你不是擁有者,這裡沒有你要找的東西。
\n \n \n
\n
\n\n\n
\n
\n
\n
Arcrun
\n
首次設定
\n
系統偵測到尚未設定擁有者帳號。
設定一組 Email 與密碼,之後只有你能進入。
\n
\n
\n \n \n \n \n
\n
\n \n
\n
\n\n\n
\n
\n
Arcrun
\n \n
總庫搜尋
\n
工作流
\n \n
設定
\n
☾ 切深色
\n
\n
\n
\n\n \n
\n
Arcrun 控制台
\n
\n
\n
\n
\n
\n
載入中
\n
\n
\n
\n
\n
\n
今日完成
\n
/
\n
\n
\n
\n
收件匣未處理
\n
\n
來自 Telegram
\n
\n
\n
\n
等你的事
\n
載入中…
\n
\n
\n
\n
\n
\n 今日路線狀態即時同步\n
\n
  • 載入中…
\n
本週
\n
    \n
    每 60 秒自動刷新
    \n
    \n
    \n
    \n\n \n
    \n
    總庫搜尋
    \n
    \n
    \n
    \n 藏書地圖\n
    \n
    地圖載入中…
    \n
    \n
    \n
    \n \n \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n\n \n
    \n
    \n \n 知識卡片\n
    \n
    \n
    載入中…
    \n
    \n
    關聯視圖
    \n
    \n
    \n
    \n
    \n
    \n\n \n
    \n
    工作流
    \n \n
    載入中…
    \n
    \n
    零件與 Recipes(框架資源)
    \n
    \n \n \n
    \n
    \n
    \n
    \n\n \n
    \n
    憑證管理
    \n
    \n 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。\n
    \n
    \n
    新增憑證
    \n
    \n \n \n \n \n
    \n
    \n
    \n
    載入中…
    \n
    \n\n \n
    \n
    分流台全部待辦・80/15/5
    \n
    \n
    載入中…
    \n
    \n\n \n
    \n
    設定
    \n
    \n
    \n
    \n
    \n
    深色模式
    \n
    預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
    \n
    \n
    \n
    \n
    \n
    \n \n
    語意搜尋
    \n
    狀態偵測中…
    \n
    \n
    \n
    \n
    MCP token 有效期(TTL)
    \n
    讀取中…
    \n
    \n 誠實佔位:此頁還不能改這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env MCP_TOKEN_TTL(mcp worker)設定。\n
    \n
    \n
    \n
    更換帳號密碼
    \n
    需輸入舊密碼驗證身分
    \n
    \n \n \n \n \n
    \n
    \n
    \n \n
    \n
    Portal 帳號密碼救援
    \n
    忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。
    \n
    \n \n \n
    \n
    \n
    \n
    \n
    系統資訊
    \n
    載入中…
    \n
    \n \n
    \n
    \n\n
    \n
    \n\n\n
    \n \n
    搜尋
    \n
    工作流
    \n \n
    設定
    \n
    \n\n
    \n\n\n\n\n"},"/favicon.ico":{"type":"image/x-icon","b64":true,"data":"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"},"/favicon.svg":{"type":"image/svg+xml","b64":false,"data":"arcrun icon"},"/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\nArcrun RAG\n\n\n\n\n\n\n\n

    正在前往搜尋頁… 沒有自動跳轉請點這裡

    \n\n\n"},"/portal/app-mount.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// App 掛載協定 v0.2 的結構自測(Arcrun#152/#82,leo 2026-08-24 拍板)\n// 跑法:node console-ui/public/portal/app-mount.test.mjs\n//\n// 這支守的是**協定的形狀**,不是長相——長相要用瀏覽器量(見 #152 的驗收留言)。\n// 之所以要有它:這一輪的病根是「出口與樣式被做成個案」——筆記 App 有返回鍵、總圖沒有;\n// CIS 表複製進 shadow 了、但 App 根本沒接上。個案會悄悄再長出來,所以把\n// 「不准再有個案」這件事寫成可執行的檢查。\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// ── ① 出口:每一個非首頁的 view 都必須有 .pagehead ────────────────────────────\n// ensureBackControls() 是往 `#v- .pagehead` 裡塞返回鍵的。少了那一列 = 那個 view\n// 悄悄沒有出口,而且沒有人會發現(leo 就是這樣卡在總圖)。\nconst viewsLine = html.match(/var VIEWS = \\[(.*?)\\];/);\nif (!viewsLine) throw new Error('抽不到 VIEWS 清單');\nconst VIEWS = viewsLine[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));\nconst HOME = (html.match(/var HOME = '([^']+)'/) || [])[1];\nchk('抽得到 VIEWS 與 HOME', VIEWS.length > 0 && !!HOME, `VIEWS=${VIEWS.length} HOME=${HOME}`);\n\nfor (const v of VIEWS) {\n if (v === HOME) continue;\n // 抓 `
    \">` 之後、下一個 view 之前那一段,看有沒有 pagehead\n const at = html.indexOf(`id=\"v-${v}\"`);\n if (at < 0) { chk(`view ${v} 存在於 HTML`, false); continue; }\n const nextView = html.indexOf('id=\"v-', at + 10);\n const block = html.slice(at, nextView < 0 ? at + 4000 : nextView);\n chk(`非首頁 view「${v}」有 .pagehead(返回鍵才掛得上)`, /class=\"pagehead\"/.test(block));\n}\n\n// ── ② 不准再有「某一頁自己的返回鍵」 ─────────────────────────────────────────\nchk('沒有殘留的個案返回鍵(app-back-btn 已撤)', !html.includes('app-back-btn'));\nchk('返回鍵只由 ensureBackControls() 產生(全檔只有一處 createElement 出 data-portal-back)',\n (html.match(/setAttribute\\('data-portal-back'/g) || []).length === 1);\nchk('返回鍵是頁首第一格(insertBefore firstChild)', /insertBefore\\(b, head\\.firstChild\\)/.test(html));\nchk('返回鍵一律回 HOME', /data-portal-back\\]'\\)\\) nav\\(HOME\\)/.test(html));\n\n// ── ③ 樣式:兩種模式與層序 ──────────────────────────────────────────────────\nchk('層序=arcrun-base < app < arcrun(全局在最上面才蓋得過 App)',\n html.includes('@layer arcrun-base, app, arcrun;'));\nchk('沒宣告=跟隨全局(預設 inherit,不是維持 App 原樣)',\n /var inherit = style !== 'own';/.test(html));\nchk('App 自己的 style 會被包進 app 層', /'@layer app\\{' \\+ st\\.textContent \\+ '\\}'/.test(html));\nchk('全局色票/CIS 都進 arcrun 層', (html.match(/'@layer arcrun\\{'/g) || []).length === 2);\nchk('樣式模式從 App 宣告來(不是 Portal 猜的)', /mountAppUi\\(host, app\\.ui_html, app\\.ui_style\\)/.test(html));\n\n// ── ④ 畫布是全局決定的(兩種模式都一樣)────────────────────────────────────\n// leo:「全局要給它多大的畫布也是全局決定的⋯⋯應該整個右側邊欄都是它的」\nchk('App 頁不吃 .page 的寬度上限與左右留白', /#v-app\\.on \\{[^}]*max-width: none;[^}]*padding: 0;/.test(html));\nchk('shadow host 由**外部文件**決定尺寸(App 在 shadow 裡改不動)',\n /#app-ui-mount \\{[^}]*width: 100%/.test(html));\n\n// ── ⑤ 隔離沒有被拆掉(這一輪的紅線)──────────────────────────────────────\nchk('App 仍然掛進 Shadow DOM', /host\\.attachShadow\\(\\{ mode: 'open' \\}\\)/.test(html));\n\nconsole.log(`\\n${fail === 0 ? '✅' : '❌'} pass=${pass} fail=${fail}`);\nprocess.exit(fail === 0 ? 0 : 1);\n"},"/portal/folder-tree.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 資料夾樹的畫面計算(InkStoneCo#44)——守 `gapWhy` 與 `rollupTree` 之間的**介面契約**。\n//\n// 🔴 這支測試存在的唯一理由,是一個 2026-08-17 真的送到瀏覽器才被抓到的 bug:\n//\n// `rollupTree()` 疊出來的合計物件用短鍵名 { total, synced, pending, unsupported, excluded }\n// 而 `gapWhy()` 當時讀的是節點的長鍵名 { total_files, unsupported_files, … }\n//\n// ⇒ 三個判斷全部拿到 undefined ⇒ **那行「為什麼沒上傳」永遠是空字串**。\n//\n// 症狀有多難發現:畫面完全正常——樹畫得出來、每個數字都對、也沒有任何 console 錯誤,\n// **只是那句解釋安靜地不見了**。而那句解釋正是 leo 要這個畫面回答的唯一那件事:\n// 「不上傳通常是不支援,比如程式碼、不支援的格式。」\n// 單元測試(雲端那半)全綠、`curl` 也看不出來(它不執行 JS)。\n//\n// ⇒ 所以這裡測的不是「gapWhy 會不會算數」,而是**它跟呼叫端講不講同一種話**。\n// 以後誰改了任一邊的鍵名,這支就會紅。\n\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\n// 抽出 gapWhy + rollupTree 兩支(錨定「函式結束」這個結構,不綁文案——\n// 同 os-split.test.mjs 2026-08-05 的教訓:綁文案會讓改字就炸)。\nconst start = html.indexOf('function gapWhy');\nconst rollupAt = html.indexOf('function rollupTree', start);\nconst endMark = 'return { sums: sums, kids: kids };';\nconst end = rollupAt < 0 ? -1 : html.indexOf(endMark, rollupAt) + endMark.length + '\\n }'.length;\nif (start < 0 || rollupAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst api = new Function(src + '; return { gapWhy: gapWhy, rollupTree: rollupTree };')();\n\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// 小幫手真的會送上來的形狀(長鍵名),刻意混著支援/不支援/整棵被剪掉的目錄。\nconst nodes = [\n { path: '', name: 'kb', parent: '-', depth: 0, total_files: 2, synced_files: 1, pending_files: 1, unsupported_files: 0, excluded_files: 0 },\n { path: 'docs', name: 'docs', parent: '', depth: 1, total_files: 5, synced_files: 2, pending_files: 1, unsupported_files: 2, excluded_files: 0 },\n { path: 'docs/img', name: 'img', parent: 'docs', depth: 2, total_files: 4, synced_files: 0, pending_files: 0, unsupported_files: 4, excluded_files: 0 },\n { path: 'src', name: 'src', parent: '', depth: 1, total_files: 4, synced_files: 0, pending_files: 0, unsupported_files: 0, excluded_files: 4 },\n { path: 'node_modules', name: 'node_modules', parent: '', depth: 1, total_files: 0, synced_files: 0, pending_files: 0, unsupported_files: 0, excluded_files: 0, skipped: true, skip_reason: '這是工具產生的' },\n];\n\nconst r = api.rollupTree(nodes);\n\n// ① 子樹合計:根要把 docs/img/src 全部疊進來(skipped 的那棵不算——沒走進去就是不知道)\nconst root = r.sums[''];\nchk('根的合計=2+5+4+4(skipped 的 node_modules 不計)', root.total === 15, JSON.stringify(root));\nchk('根的已同步=1+2', root.synced === 3, JSON.stringify(root));\n\n// ② 🔴 本檔的重點:gapWhy 吃得下 rollupTree 吐出來的東西\nconst why = api.gapWhy(root);\nchk('gapWhy(合計) 不是空字串(鍵名對得上)', why.length > 0, JSON.stringify(why));\nchk('gapWhy 講得出不支援的份數', why.includes('6 份'), why); // 2(docs) + 4(img)\nchk('gapWhy 講得出不收的份數', why.includes('4 份'), why); // src\nchk('gapWhy 講得出處理中的份數', why.includes('2 份'), why); // 根 1 + docs 1\n\n// ③ 差額必須解釋得了(leo 的規格:兩個數字不相等是正常的,但差額要說得出來)\nconst gap = root.total - root.synced;\nchk('差額 == 不支援 + 不收 + 處理中', gap === root.unsupported + root.excluded + root.pending,\n `gap=${gap} unsup=${root.unsupported} excl=${root.excluded} pend=${root.pending}`);\n\n// ④ 完全同步的資料夾不該多嘴\nchk('沒有差額時 gapWhy 回空字串',\n api.gapWhy({ total: 3, synced: 3, pending: 0, unsupported: 0, excluded: 0 }) === '');\n\nconsole.log(`\\n${pass} passed, ${fail} failed`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Portal\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n\n
    \n
    \n
    \n
    \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
    \n
    知識入口・Portal
    \n
    \n
    \n \n \n \n
    \n
    \n \n
    \n 忘記密碼了?寄一條「修改密碼」連結給我\n
    \n
    \n \n \n
    \n
    \n \n
    \n
    \n\n\n
    \n
    \n
    \n
    設定新密碼
    \n
    \n
    \n
    \n \n
    \n
    \n
    \n\n\n
    \n
    \n
    \n
    \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
    \n
    歡迎!先建立你的帳號
    \n
    \n
    \n \n \n \n \n
    \n \n \n
    \n
    這組帳密就是之後登入知識庫用的,只需要設定這一次。
    \n
    \n
    \n\n\n
    \n
    \n
    \"arcrun\"\"arcrun\"
    \n \n
    App 界面
    \n
    App 市集
    \n
    AR 設定
    \n
    App 開發
    \n
    ☾ 切深色
    \n
    \n
    \n
    \n\n \n
    \n
    App 界面
    \n
    載入中…
    \n
    \n\n \n
    \n
    搜尋
    \n
    \n
    \n \n \n
    \n
    \n \n \n \n
    \n \n
    \n
    AI 問答・答案附出處,可回搜尋驗證
    \n
    \n \n \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n\n \n
    \n
    總圖
    \n
    \n
    \n
    \n 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版\n (給 AI 注入用的總庫目錄)。\n
    \n
    \n
    \n\n \n
    \n
    \n \n 知識卡片\n
    \n
    載入中…
    \n
    \n\n \n
    \n
    App 市集瀏覽、安裝、或上傳你自己做的 App
    \n
    \n
    \n
    \n
    \n
    上傳你的 App
    \n
    打包成一份宣告,讓其他人也能安裝——尚未接後端,先佔位
    \n
    \n
    \n
    已安裝
    \n
    載入中…
    \n
    可安裝
    \n \n
    載入中…
    \n
    \n
    \n\n \n
    \n
    上傳Markdown / 純文字
    \n
    \n 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。\n
    \n
    \n
    把 .md / .txt 檔案拖放到這裡
    \n
    \n \n \n
    \n
    \n
    \n\n \n \n
    \n
    App 開發Workflows/零件/Recipe/Credential
    \n
    \n
    \n
    \n
    \n
    叫 AI 幫你做一個 App
    \n
    用一句話描述你要什麼——尚未接後端,先佔位
    \n
    \n
    \n
    工作環境
    \n
    \n
    \n Workflows\n
    \n
    \n 資料上傳\n
    \n
    零件尚未接進 Portal
    \n
    Recipe尚未接進 Portal
    \n
    Credential尚未接進 Portal
    \n
    \n
    \n
    \n\n
    \n
    工作流唯讀・系統狀態
    \n
    \n 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。\n
    \n
    載入中…
    \n
    \n\n \n
    \n
    \n \n App\n \n
    \n
    載入中…
    \n
    \n\n \n
    \n
    設定
    \n \n
    \n
    帳號
    \n
    管理
    \n
    \n
    \n \n
    \n
    \n
    \n
    \n 版本\n \n
    \n
    查詢中…
    \n
    \n \n
    \n
    \n
    \n
    我的帳號
    \n
    載入中…
    \n
    \n
    \n
    \n
    \n
    深色模式
    \n
    預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
    \n
    \n
    \n
    \n
    \n \n
    \n
    更改密碼
    \n
    需輸入舊密碼驗證身分;新密碼至少 8 碼
    \n
    \n \n \n \n \n
    \n
    \n
    \n \n
    \n \n
    \n 你的知識庫網址(小幫手連線用)\n \n \n
    \n
    同步小幫手
    \n
    把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。
    \n
    \n 下載 Mac 版\n
    封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
    \n
    \n
    \n \n
    \n
    \n 你的 MCP 網址(給你的 AI 連線用)\n \n \n
    \n
    接上你的 AI(MCP)
    \n
    把上面這串網址貼到 Claude、ChatGPT 等 AI 的「新增自訂連接器」欄位,就能讓你的 AI 直接查這個知識庫。
    \n
    \n \n
    \n
    AI 設定
    \n
    \n 這裡不需要任何設定。聊天問答用的是你自己 Cloudflare 帳號內建的 AI,裝好就能直接問。
    \n 文件整理成知識卡的部分,請在同步小幫手(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。\n
    \n
    \n \n
    \n
    疑難排解
    \n
    要回報問題,請到你電腦上的 Arcrun(同步小幫手)「版本與更新」頁——那裡的「疑難排解」按一下就能匯出完整診斷檔給我們(只有統計數字,不含你的任何文件內容)。
    \n
    \n \n
    \n
    \n\n \n
    \n
    管理帳號與知識庫授權
    \n
    \n
    帳號
    \n
    管理
    \n
    \n\n
    執行紀錄保留期
    \n
    \n
    保留天數
    \n
    執行紀錄是稽核資料,超過保留天數會被每日自動清除;預設 90 天(3 個月),也可設為「不刪除」(企業稽核用途)。
    \n
    \n \n \n \n
    \n
    \n
    \n
    \n\n
    帳號管理
    \n
    \n
    新增同仁帳號
    \n
    建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
    \n
    \n \n \n \n \n
    \n
    \n
    \n
    \n
    載入中…
    \n\n
    庫目錄管理
    \n
    \n 你的知識庫網址(小幫手連線用)\n \n \n
    \n
    \n \n 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。\n
    \n
    載入中…
    \n
    \n\n
    \n
    \n\n\n
    \n
    App 界面
    \n
    App 市集
    \n
    AR 設定
    \n
    App 開發
    \n
    \n\n
    \n\n\n\n"},"/portal/os-split.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)\n//\n// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語\n// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。\n// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。\nconst start = html.indexOf('var DAEMON_BASE_DEFAULT');\nconst hintAt = html.indexOf('function daemonHint', start);\nconst endMark = '\\n }';\nconst end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;\nif (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst cases = [\n ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n ['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'],\n ['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'],\n ['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n];\nlet pass=0, fail=0;\nconst chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} };\n\nfor (const [name, ua] of cases) {\n const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};');\n const api = fn({userAgent: ua}, {});\n const d = api.daemonPick();\n const label = d.sure ? d.pick.label : '(兩個都給)';\n const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url;\n console.log(`\\n[${name}] sure=${d.sure} → ${label}`);\n console.log(` url: ${url}`);\n if (name==='Windows') {\n chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);\n chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));\n }\n if (name==='Mac') {\n // 2026-08-05:Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip\n // ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。\n chk('Mac 給 dmg(不是 zip)', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));\n chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));\n }\n if (name==='iPhone' || name==='Linux') {\n // iPhone 含 \"Mac OS X\" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」\n if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false);\n if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure);\n }\n}\n// 舊 key 相容\nconst fn2 = new Function('navigator','window', src + '; return daemonBase();');\nconsole.log('\\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}}));\nchk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/');\nchk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/');\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/retrieval-tree.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 檢索過程樹(Arcrun#44 第二輪,leo 2026-08-18「希望以樹狀圖來展示」)的資料組裝測試。\n// 只測**樹的資料**,不測它怎麼被畫出來(那在 tree-render.test.mjs),\n// 我們自己要為之負責的是「層級對不對、有沒有把讀過的東西吞掉」。\n// 跑法:node console-ui/public/portal/retrieval-tree.test.mjs\nimport fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0, j = html.indexOf('{', i);\n for (let k = j; k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst src = ['esc', 'srcLocalPath', 'machineLabelMap', 'srcCrumbs', 'retrievalTreeData', 'normalizeTreeNode', 'visibleRows'].map(grab).join('\\n');\nconst fn = new Function(src + '\\nvar SEARCH_ROUTE_LABEL = { graph: \"圖譜命中子庫\", index: \"索引退回選庫\", all: \"全庫讀取\" };'\n + '\\nreturn { srcCrumbs, machineLabelMap, retrievalTreeData, normalizeTreeNode, visibleRows };')();\n\nlet pass = 0, fail = 0;\nconst t = (l, c, e = '') => { c ? (console.log('PASS:', l), pass++) : (console.log('FAIL:', l, e), fail++); };\nconst txt = (n) => String(n.content).replace(/<[^>]*>/g, '');\n\n// 2026-08-18 從 youlin stage 實跑 /portal/data/chat 抓下來的真回應形狀(narrative 真的是空字串)\nconst REAL = {\n route: 'graph', vector_used: false, libraries_considered: 8,\n libraries: [{ library: 'testkb', score: 1.69, via: 'graph', matched_entities: ['Arcrun124驗證卡'] }],\n indexes_read: [\n { library: 'general', narrative: '', selected: false, top_entities: [], triplet_count: 0, entry_count: 100 },\n { library: 'testkb', narrative: '', selected: true, top_entities: ['Arcrun124驗證卡'], triplet_count: 4, entry_count: 9 },\n { library: 'kb', narrative: '', selected: false, top_entities: ['knowledge-base'], triplet_count: 7, entry_count: 16 }\n ],\n pages_read: [{ page: 'Arcrun124驗證卡', library: 'testkb', chars: 231, source: 'kb://test/arcrun-124-verify.md#0' }]\n};\n\n// ① 層級=leo 畫的那張:搜尋詞 → 命中子庫 → 索引敘事 → 原文片段\nconst tree = fn.retrievalTreeData('Arcrun124驗證卡是什麼?', REAL);\nt('第 1 層是搜尋詞', txt(tree).indexOf('Arcrun124驗證卡是什麼?') === 0, txt(tree));\nconst lib = tree.children[0];\nt('第 2 層是命中子庫(帶分數與命中實體)', /testkb/.test(txt(lib)) && /1\\.69/.test(txt(lib)) && /命中實體/.test(txt(lib)), txt(lib));\nconst idx = lib.children[0];\nt('第 3 層是該子庫的索引敘事', /三元組/.test(txt(idx)) && /筆條目/.test(txt(idx)), txt(idx));\nconst page = idx.children[0];\nt('第 4 層是原文片段(掛在索引敘事底下)', /Arcrun124驗證卡/.test(txt(page)) && /231 字/.test(txt(page)), txt(page));\nt('原文片段帶資料夾麵包屑', /test › arcrun-124-verify\\.md/.test(txt(page)), txt(page));\n\n// ② narrative 是空字串時不假裝有敘事(線上真資料就是這樣)\nt('沒有敘事就說沒有,不留空白', /索引沒有敘事/.test(txt(idx)), txt(idx));\n\n// ③ 沒被選用的子庫不可以被吞掉,但要收合起來不擠掉主線\nconst rest = tree.children[tree.children.length - 1];\nt('沒讀原文的子庫收在一個節點底下', /考慮過但沒讀原文(2 個子庫)/.test(txt(rest)), txt(rest));\nt('那個節點預設收合', rest.payload && rest.payload.fold === 1, JSON.stringify(rest.payload));\nt('主線只有命中的子庫', tree.children.length === 2, `實得 ${tree.children.length} 條分支`);\n\n// ④ 讀了原文、但沒出現在 libraries[] 的子庫(route=all)也要長出來\nconst allRoute = fn.retrievalTreeData('問句', {\n route: 'all', libraries: [], indexes_read: [],\n pages_read: [{ page: 'P', library: 'kb', chars: 10, source: 'kb://a/b/c.md#2' }]\n});\nt('route=all 時仍有子庫分支', allRoute.children.length === 1 && /kb/.test(txt(allRoute.children[0])), txt(allRoute.children[0]));\nt('沒有索引敘事時不生假的中間層', /^P/.test(txt(allRoute.children[0].children[0])), txt(allRoute.children[0].children[0]));\n\n// ⑤ 完全沒資料時不要生一棵空樹\nconst empty = fn.retrievalTreeData('問句', { route: 'all', libraries: [], indexes_read: [], pages_read: [] });\nt('沒有任何子庫時說明原因', /全庫作答|沒有回報/.test(txt(empty.children[0])), txt(empty.children[0]));\n\n// ⑥ 麵包屑:資料夾可以有很多層,全部拆開;機器那一層現在是「沒有」\nconst c = fn.srcCrumbs('kb://RFP/2026/design.md#3', 'kb');\nt('多層資料夾全部拆開', JSON.stringify(c.dirs) === '[\"RFP\",\"2026\"]', JSON.stringify(c.dirs));\nt('檔名拆得出來', c.file === 'design.md', c.file);\nt('片段編號拆得出來', c.frag === '3', c.frag);\nt('子庫帶得進來', c.library === 'kb', c.library);\nt('機器那一層是 null(資料裡真的沒有)', c.machine === null, String(c.machine));\nt('相對路徑原樣留著(複製給用戶去本機找)', c.path === 'RFP/2026/design.md', c.path);\n\nconst g = fn.srcCrumbs('gitea:Leo/InkStoneCo@wiki/x.md#1', 'kb');\nt('非 kb:// 來源不硬拆成假麵包屑', g.path === '' && g.file === '' && g.dirs.length === 0, JSON.stringify(g));\n\n// ⑦ 節點內容是**單行**(Arcrun#144 判準第 3 條:字級行距接近作業系統檔案總管)。\n// 2026-08-19 之前每個節點塞 2–3 行(
    換行),那正是 leo 說的「上下間距很大」。\n// 一列放不下的部分由 CSS 截斷、全文掛 title,所以資料這端不准再自己換行。\nconst everyContent = [tree, lib, idx, page, rest].map(n => String(n.content)).join('');\nt('節點內容不含換行標籤(一個節點=一列)', !//i.test(everyContent),\n (everyContent.match(/]*>/gi) || []).join(' '));\nt('同一列裡的多段資訊用分隔點接起來', //.test(String(page.content)), String(page.content));\n\n// ⑧ 判準第 2 條的算式:列數 × 一列 23px 要塞得進 1080p(收合的那支只算它自己一列)\nt('收合的節點只算一列', fn.visibleRows(rest) === 1, String(fn.visibleRows(rest)));\nt('整棵樹的列數=展開狀態下看得到幾列', fn.visibleRows(fn.normalizeTreeNode(tree)) === 5,\n String(fn.visibleRows(fn.normalizeTreeNode(tree))));\nt('單一節點也算一列(不會回 0)', fn.visibleRows({ content: 'x', children: [] }) === 1);\n\n// ⑨ 機器那一層(inkstone/mira#6 補上的兩格:machine=比對鍵、machine_label=顯示名)\nconst withM = fn.srcCrumbs('kb://RFP/daemon-真機驗收.md#0', 'mira6-watch',\n { machine: 'youlinhsieh@Leo-MBA', machine_label: '教育部 Leo 的 Mac' });\nt('有機器就拿顯示名', withM.machineLabel === '教育部 Leo 的 Mac', String(withM.machineLabel));\nt('比對鍵原樣留著(樹上分支要用它當 key)', withM.machine === 'youlinhsieh@Leo-MBA', String(withM.machine));\nt('機器名沒有混進路徑', withM.path === 'RFP/daemon-真機驗收.md' && withM.dirs.join('/') === 'RFP', withM.path);\n\nconst onlyKey = fn.srcCrumbs('kb://a/b.md#0', 'lib', { machine: 'u@Host' });\nt('只有比對鍵沒有顯示名 → 顯示名退回比對鍵', onlyKey.machineLabel === 'u@Host', String(onlyKey.machineLabel));\n\nconst noM = fn.srcCrumbs('kb://a/b.md#0', 'lib', {});\nt('舊資料沒有這兩格 → machine 是 null(畫面顯示未知來源)', noM.machine === null && noM.machineLabel === null, JSON.stringify(noM.machine));\nconst emptyM = fn.srcCrumbs('kb://a/b.md#0', 'lib', { machine: '', machine_label: '' });\nt('空字串一律當沒有,不顯示成空白機器', emptyM.machine === null && emptyM.machineLabel === null, JSON.stringify(emptyM));\n\n// 同一個相對路徑來自兩台機器 → 樹上要是兩支,不可以被併成一支\nconst twoMachines = fn.retrievalTreeData('問句', { route: 'graph',\n libraries: [{ library: 'mira6-watch', score: 2.1, via: 'graph', matched_entities: ['x'] }],\n indexes_read: [{ library: 'mira6-watch', narrative: '', selected: true, triplet_count: 1, entry_count: 2 }],\n pages_read: [\n { page: 'P', library: 'mira6-watch', chars: 10, source: 'kb://RFP/same.md#0', machine: 'u@Leo-MBA', machine_label: 'Leo 的 Mac' },\n { page: 'P', library: 'mira6-watch', chars: 10, source: 'kb://RFP/same.md#0', machine: 'u@Leo-iMac', machine_label: 'Leo 的 iMac' } ] });\nconst pageNodes = twoMachines.children[0].children[0].children;\nt('同路徑不同機器=兩支,不被併掉', pageNodes.length === 2, String(pageNodes.length));\nt('兩支各自標出自己的機器',\n /Leo 的 Mac/.test(pageNodes[0].content) && /Leo 的 iMac/.test(pageNodes[1].content),\n txt(pageNodes[0]) + ' / ' + txt(pageNodes[1]));\nconst noMachinePage = fn.retrievalTreeData('問句', { route: 'graph', libraries: [], indexes_read: [],\n pages_read: [{ page: 'P', library: 'kb', chars: 1, source: 'kb://a.md#0' }] });\nt('沒有機器的片段標「未知來源」,不留空', /未知來源/.test(noMachinePage.children[0].children[0].content));\n\n// ⑩ 同一台機器只能有一個顯示名(youlin stage 2026-08-18 實測到的真實情況:\n// 同一把 youlinhsieh@Leo-MBA,改名前那筆 label 就是 ID 本身,改名後是「教育部 Leo 的 Mac」)\nconst REALPAIR = [\n { page: '資料夾總覽:mira6-watch', library: 'mira6-watch', chars: 40, source: 'kb://x.md#0',\n machine: 'youlinhsieh@Leo-MBA', machine_label: 'youlinhsieh@Leo-MBA' },\n { page: 'daemon-真機驗收', library: 'mira6-watch', chars: 93, source: 'kb://RFP/daemon.md#0',\n machine: 'youlinhsieh@Leo-MBA', machine_label: '教育部 Leo 的 Mac' }\n];\nconst lb = fn.machineLabelMap(REALPAIR);\nt('有人取過名字就全篇用那個名字', lb['youlinhsieh@Leo-MBA'] === '教育部 Leo 的 Mac', JSON.stringify(lb));\nt('舊那筆也跟著顯示成新名字(同一台不會長成兩台)',\n fn.srcCrumbs('kb://x.md#0', 'mira6-watch', REALPAIR[0], lb).machineLabel === '教育部 Leo 的 Mac');\nt('比對鍵不受顯示名影響', fn.srcCrumbs('kb://x.md#0', 'mira6-watch', REALPAIR[0], lb).machine === 'youlinhsieh@Leo-MBA');\nt('誰都沒取過名字 → 退回顯示 ID', fn.machineLabelMap([{ machine: 'u@H', machine_label: 'u@H' }])['u@H'] === 'u@H');\nt('兩台不同機器不會被併成一個名字',\n Object.keys(fn.machineLabelMap([{ machine: 'a@X', machine_label: 'X 機' }, { machine: 'b@Y', machine_label: 'Y 機' }])).length === 2);\n\nconst treeReal = fn.retrievalTreeData('問句', { route: 'graph',\n libraries: [{ library: 'mira6-watch', score: 2.1, via: 'graph', matched_entities: ['x'] }],\n indexes_read: [{ library: 'mira6-watch', narrative: '', selected: true, triplet_count: 1, entry_count: 2 }],\n pages_read: REALPAIR });\nconst both = treeReal.children[0].children[0].children.map(n => n.content).join(' ');\nt('樹上兩個片段標的是同一個機器名', (both.match(/教育部 Leo 的 Mac/g) || []).length === 2, both.slice(0, 200));\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/safejson.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n\n// 抽出 safeJson 與 friendlyErr 求值\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d=0, j=html.indexOf('{', i);\n for (let k=j;k{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)};\n\n// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁)\nconst html404 = '404 Not Found';\nawait fn.safeJson({ text: () => Promise.resolve(html404) })\n .then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null))\n .catch(e => t('404 HTML → 不該拋錯', false, e.message));\n\nawait fn.safeJson({ text: () => Promise.resolve('') })\n .then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}'))\n .catch(() => t('空回應 → 不該拋錯', false));\n\nawait fn.safeJson({ text: () => Promise.resolve('{\"error\":\"帳號或密碼不對\"}') })\n .then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), )\n .catch(() => t('正常 JSON 不該拋錯', false));\n\n// ② friendlyErr:不可把技術訊息噴給使用者\nconst leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4'));\nt('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`);\nt('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`);\n\nconst net = fn.friendlyErr(new Error('Failed to fetch'));\nt('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`);\n\nconst ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組'));\nt('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`);\n\nconst stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined'));\nt('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`);\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/tree-render.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 樹的**畫法**測試(Arcrun#144,leo 2026-08-19 打回 markmap 之後)。\n//\n// 取代舊的 tree-links.test.mjs——那支測的是「把 markmap 的貝茲曲線改寫成 L 型折線」,\n// 而那整段程式碼連同 markmap 一起被移除了(形狀錯的不是線,是佈局;見 index.html\n// renderTree() 的技術選型註解)。\n//\n// 這支守的是**我們自己寫的那一段**:把樹資料攤成巢狀
    的字串拼接。\n// 摺疊行為本身是瀏覽器的,不測;縮排是 CSS 的,不測。\n// 跑法:node console-ui/public/portal/tree-render.test.mjs\nimport fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0;\n for (let k = html.indexOf('{', i); k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst fn = new Function(\n ['esc', 'treeHtml', 'treeKidsHtml', 'treeNodeHtml', 'titleAttr', 'normalizeTreeNode', 'visibleRows',\n 'gapWhy', 'rollupTree', 'folderTreeData', 'adminLibsTreeData', 'libTreeNode', 'folderKidsData'].map(grab).join('\\n')\n // 同步庫總覽的第四層是「點開才抓」的,資料放在這兩張表裡(畫面端的快取)。\n // 測試自己當那份快取,就能把「還沒抓/抓過但沒有/抓到了」三種狀態都走一遍。\n + '\\nvar folderTrees = {}, treeOpen = {};'\n + '\\nreturn { treeHtml, treeKidsHtml, treeNodeHtml, titleAttr, normalizeTreeNode, visibleRows,'\n + ' adminLibsTreeData, folderTreeData,'\n + ' setFolderTree: function (n, t) { folderTrees[n] = t; },'\n + ' setOpen: function (n, v) { treeOpen[n] = v; } };')();\n\nlet pass = 0, fail = 0;\nconst t = (l, c, e = '') => { c ? (console.log('PASS:', l), pass++) : (console.log('FAIL:', l, e), fail++); };\n\nconst leaf = (c) => ({ content: c, children: [] });\nconst TREE = {\n content: '問句', children: [\n { content: 'kb', children: [leaf('頁 A'), leaf('頁 B')] },\n { content: '考慮過但沒讀原文', payload: { fold: 1 }, children: [leaf('other')] }\n ]\n};\n\n// ① 有小孩=可摺疊的
    ;沒小孩=一列,不生假的可展開節點\nconst out = fn.treeHtml(TREE);\nt('外層是 .ftree', out.startsWith('
    '), out.slice(0, 40));\nt('有小孩的節點是
    ', (out.match(/
    ,是一列 .ft-leaf', (out.match(/ft-row ft-leaf/g) || []).length === 3,\n String((out.match(/ft-row ft-leaf/g) || []).length));\nt('可摺疊的節點把標題放在 (點得到的就是這一列)', //.test(out));\n\n// ② 縮排靠巢狀,不靠算 padding ⇒ 深度沒有上限\nt('每一層小孩包在 .ft-kids 裡', (out.match(/
    /g) || []).length === 3,\n String((out.match(/
    /g) || []).length));\nlet deep = leaf('底'); for (let i = 0; i < 12; i++) deep = { content: 'L' + i, children: [deep] };\nt('12 層照樣攤得開(沒有寫死的深度上限)',\n (fn.treeHtml(deep).match(/
    /g) || []).length === 12);\n\n// ③ payload.fold ⇒ 預設收起來(那一支不能被展開,也不能被吞掉)\nconst folded = out.slice(out.indexOf('考慮過') - 200, out.indexOf('考慮過'));\nt('fold 的那一支沒有 open 屬性', /
    /.test(out), '');\nt('收起來的那支內容仍在 DOM 裡(不隱瞞,只是收合)', /other/.test(out));\n\n// ④ 節點內容是 HTML(三個入口的異質節點靠這個分)——不可以被跳脫掉\nt('節點的 HTML 原樣留著', /kb<\\/b>/.test(out) && /頁 A<\\/b>/.test(out));\n\n// ⑤ 一列放不下會截斷 ⇒ 全文必須掛在 title 上(=檔案總管對長檔名的做法)\nt('title 是剝掉標籤的純文字', fn.titleAttr('·乙') === ' title=\"甲·乙\"',\n fn.titleAttr('·乙'));\nt('title 裡的引號要跳脫,不能把屬性打斷', /"|"|'/.test(fn.titleAttr('say \"hi\"')) || !/\"hi\"/.test(fn.titleAttr('say \"hi\"')),\n fn.titleAttr('say \"hi\"'));\nt('沒有文字就不掛空的 title', fn.titleAttr('') === '');\n\n// ⑥ 多個根(資料夾樹會有)要全部畫出來,不是只畫第一個\nconst multi = fn.treeHtml([leaf('根一'), leaf('根二')]);\nt('陣列=多個根,全部畫出來', /根一/.test(multi) && /根二/.test(multi));\n\n// ⑦ 註腳(資料夾樹的「數字是已同步/總共」那段)掛得上去,且只在有內容時出現\nt('有註腳就畫在樹的後面', /
    說明<\\/div><\\/div>$/.test(fn.treeHtml(leaf('x'), '說明')));\nt('沒註腳就不生空的區塊', !/ft-foot/.test(fn.treeHtml(leaf('x'))));\n\n// ⑧ 呼叫端漏寫 children 不會炸(葉子寫成 { content } 是最常見的手滑)\nt('缺 children 的節點補得回來', fn.normalizeTreeNode({ content: 'x' }).children.length === 0);\nt('缺 children 也畫得出來', /ft-leaf/.test(fn.treeHtml({ content: 'x' })));\n\n// ⑨ 🔴 判準第 2 條的算式:列數 × 23px 要塞得進 1080p。\n// 這支測的是「列數算得對」——收合的那支只算它自己一列。\nt('列數=展開狀態下看得到的列', fn.visibleRows(fn.normalizeTreeNode(TREE)) === 5,\n String(fn.visibleRows(fn.normalizeTreeNode(TREE)))); // 根 + kb + 2 頁 + 收合的那支\nconst wide = { content: 'r', children: [] };\nfor (let i = 0; i < 8; i++) wide.children.push({ content: 'lib' + i, children: [{ content: 'idx', children: Array.from({ length: 2 }, () => leaf('p')) }] });\nt('8 子庫 × (索引+2 片原文) = 33 列,×23px ≈ 759px,1080p 放得下',\n fn.visibleRows(fn.normalizeTreeNode(wide)) === 33 && fn.visibleRows(fn.normalizeTreeNode(wide)) * 23 < 900,\n String(fn.visibleRows(fn.normalizeTreeNode(wide))));\n\n// ⑩ 行尾那一格(庫目錄的「移除」鈕)——名字被截斷時它不能跟著消失,\n// 所以它必須在 .ft-lbl **外面**,而且不出現在 title 裡(title 是「這一列叫什麼」)。\nconst withTail = fn.treeHtml({ content: '很長的名字', tail: '', children: [] });\nt('tail 畫在 .ft-lbl 外面(不參與截斷)', /<\\/span>\n
    \n
    \n
    \n
    \n
    \n
    載入中
    \n
    \n
    \n
    \n
    \n
    \n
    今日完成
    \n
    /
    \n
    \n
    \n
    \n
    收件匣未處理
    \n
    \n
    來自 Telegram
    \n
    \n
    \n
    \n
    等你的事
    \n
    載入中…
    \n
    \n
    \n
    今日路線
    \n
    • 載入中…
    \n
    本週
    \n
      \n
      系統狀況live 健康信號
      \n
      載入中…
      \n
      每 60 秒自動刷新
      \n 進入完整控制台 ›\n\n\n\n\n"},"/console/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Console\n\n\n\n\n\n\n\n\n
      \n
      \n
      \n
      Arcrun
      \n
      私人系統・僅供擁有者進入
      \n
      \n
      \n \n \n \n
      \n
      \n
      這是一個人的智慧總部。
      若你不是擁有者,這裡沒有你要找的東西。
      \n \n \n
      \n
      \n\n\n
      \n
      \n
      \n
      Arcrun
      \n
      首次設定
      \n
      系統偵測到尚未設定擁有者帳號。
      設定一組 Email 與密碼,之後只有你能進入。
      \n
      \n
      \n \n \n \n \n
      \n
      \n \n
      \n
      \n\n\n
      \n
      \n
      Arcrun
      \n \n
      總庫搜尋
      \n
      工作流
      \n \n
      設定
      \n
      ☾ 切深色
      \n
      \n
      \n
      \n\n \n
      \n
      Arcrun 控制台
      \n
      \n
      \n
      \n
      \n
      \n
      載入中
      \n
      \n
      \n
      \n
      \n
      \n
      今日完成
      \n
      /
      \n
      \n
      \n
      \n
      收件匣未處理
      \n
      \n
      來自 Telegram
      \n
      \n
      \n
      \n
      等你的事
      \n
      載入中…
      \n
      \n
      \n
      \n
      \n
      \n 今日路線狀態即時同步\n
      \n
      • 載入中…
      \n
      本週
      \n
        \n
        每 60 秒自動刷新
        \n
        \n
        \n
        \n\n \n
        \n
        總庫搜尋
        \n
        \n
        \n
        \n 藏書地圖\n
        \n
        地圖載入中…
        \n
        \n
        \n
        \n \n \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n\n \n
        \n
        \n \n 知識卡片\n
        \n
        \n
        載入中…
        \n
        \n
        關聯視圖
        \n
        \n
        \n
        \n
        \n
        \n\n \n
        \n
        工作流
        \n \n
        載入中…
        \n
        \n
        零件與 Recipes(框架資源)
        \n
        \n \n \n
        \n
        \n
        \n
        \n\n \n
        \n
        憑證管理
        \n
        \n 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。\n
        \n
        \n
        新增憑證
        \n
        \n \n \n \n \n
        \n
        \n
        \n
        載入中…
        \n
        \n\n \n
        \n
        分流台全部待辦・80/15/5
        \n
        \n
        載入中…
        \n
        \n\n \n
        \n
        設定
        \n
        \n
        \n
        \n
        \n
        深色模式
        \n
        預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
        \n
        \n
        \n
        \n
        \n
        \n \n
        語意搜尋
        \n
        狀態偵測中…
        \n
        \n
        \n
        \n
        MCP token 有效期(TTL)
        \n
        讀取中…
        \n
        \n 誠實佔位:此頁還不能改這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env MCP_TOKEN_TTL(mcp worker)設定。\n
        \n
        \n
        \n
        更換帳號密碼
        \n
        需輸入舊密碼驗證身分
        \n
        \n \n \n \n \n
        \n
        \n
        \n \n
        \n
        Portal 帳號密碼救援
        \n
        忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。
        \n
        \n \n \n
        \n
        \n
        \n
        \n
        系統資訊
        \n
        載入中…
        \n
        \n \n
        \n
        \n\n
        \n
        \n\n\n
        \n \n
        搜尋
        \n
        工作流
        \n \n
        設定
        \n
        \n\n
        \n\n\n\n\n"},"/favicon.ico":{"type":"image/x-icon","b64":true,"data":"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"},"/favicon.svg":{"type":"image/svg+xml","b64":false,"data":"arcrun icon"},"/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\nArcrun RAG\n\n\n\n\n\n\n\n

        正在前往搜尋頁… 沒有自動跳轉請點這裡

        \n\n\n"},"/portal/app-mount.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// App 掛載協定 v0.2 的結構自測(Arcrun#152/#82,leo 2026-08-24 拍板)\n// 跑法:node console-ui/public/portal/app-mount.test.mjs\n//\n// 這支守的是**協定的形狀**,不是長相——長相要用瀏覽器量(見 #152 的驗收留言)。\n// 之所以要有它:這一輪的病根是「出口與樣式被做成個案」——筆記 App 有返回鍵、總圖沒有;\n// CIS 表複製進 shadow 了、但 App 根本沒接上。個案會悄悄再長出來,所以把\n// 「不准再有個案」這件事寫成可執行的檢查。\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// ── ① 出口:每一個非首頁的 view 都必須有 .pagehead ────────────────────────────\n// ensureBackControls() 是往 `#v- .pagehead` 裡塞返回鍵的。少了那一列 = 那個 view\n// 悄悄沒有出口,而且沒有人會發現(leo 就是這樣卡在總圖)。\nconst viewsLine = html.match(/var VIEWS = \\[(.*?)\\];/);\nif (!viewsLine) throw new Error('抽不到 VIEWS 清單');\nconst VIEWS = viewsLine[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));\nconst HOME = (html.match(/var HOME = '([^']+)'/) || [])[1];\nchk('抽得到 VIEWS 與 HOME', VIEWS.length > 0 && !!HOME, `VIEWS=${VIEWS.length} HOME=${HOME}`);\n\nfor (const v of VIEWS) {\n if (v === HOME) continue;\n // 抓 `
        \">` 之後、下一個 view 之前那一段,看有沒有 pagehead\n const at = html.indexOf(`id=\"v-${v}\"`);\n if (at < 0) { chk(`view ${v} 存在於 HTML`, false); continue; }\n const nextView = html.indexOf('id=\"v-', at + 10);\n const block = html.slice(at, nextView < 0 ? at + 4000 : nextView);\n chk(`非首頁 view「${v}」有 .pagehead(返回鍵才掛得上)`, /class=\"pagehead\"/.test(block));\n}\n\n// ── ② 不准再有「某一頁自己的返回鍵」 ─────────────────────────────────────────\nchk('沒有殘留的個案返回鍵(app-back-btn 已撤)', !html.includes('app-back-btn'));\nchk('返回鍵只由 ensureBackControls() 產生(全檔只有一處 createElement 出 data-portal-back)',\n (html.match(/setAttribute\\('data-portal-back'/g) || []).length === 1);\nchk('返回鍵是頁首第一格(insertBefore firstChild)', /insertBefore\\(b, head\\.firstChild\\)/.test(html));\nchk('返回鍵一律回 HOME', /data-portal-back\\]'\\)\\) nav\\(HOME\\)/.test(html));\n\n// ── ③ 樣式:兩種模式與層序 ──────────────────────────────────────────────────\nchk('層序=arcrun-base < app < arcrun(全局在最上面才蓋得過 App)',\n html.includes('@layer arcrun-base, app, arcrun;'));\nchk('沒宣告=跟隨全局(預設 inherit,不是維持 App 原樣)',\n /var inherit = style !== 'own';/.test(html));\nchk('App 自己的 style 會被包進 app 層', /'@layer app\\{' \\+ st\\.textContent \\+ '\\}'/.test(html));\nchk('全局色票/CIS 都進 arcrun 層', (html.match(/'@layer arcrun\\{'/g) || []).length === 2);\nchk('樣式模式從 App 宣告來(不是 Portal 猜的)', /mountAppUi\\(host, app\\.ui_html, app\\.ui_style\\)/.test(html));\n\n// ── ④ 畫布是全局決定的(兩種模式都一樣)────────────────────────────────────\n// leo:「全局要給它多大的畫布也是全局決定的⋯⋯應該整個右側邊欄都是它的」\nchk('App 頁不吃 .page 的寬度上限與左右留白', /#v-app\\.on \\{[^}]*max-width: none;[^}]*padding: 0;/.test(html));\nchk('shadow host 由**外部文件**決定尺寸(App 在 shadow 裡改不動)',\n /#app-ui-mount \\{[^}]*width: 100%/.test(html));\n\n// ── ⑤ 隔離沒有被拆掉(這一輪的紅線)──────────────────────────────────────\nchk('App 仍然掛進 Shadow DOM', /host\\.attachShadow\\(\\{ mode: 'open' \\}\\)/.test(html));\n\nconsole.log(`\\n${fail === 0 ? '✅' : '❌'} pass=${pass} fail=${fail}`);\nprocess.exit(fail === 0 ? 0 : 1);\n"},"/portal/folder-tree.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 資料夾樹的畫面計算(InkStoneCo#44)——守 `gapWhy` 與 `rollupTree` 之間的**介面契約**。\n//\n// 🔴 這支測試存在的唯一理由,是一個 2026-08-17 真的送到瀏覽器才被抓到的 bug:\n//\n// `rollupTree()` 疊出來的合計物件用短鍵名 { total, synced, pending, unsupported, excluded }\n// 而 `gapWhy()` 當時讀的是節點的長鍵名 { total_files, unsupported_files, … }\n//\n// ⇒ 三個判斷全部拿到 undefined ⇒ **那行「為什麼沒上傳」永遠是空字串**。\n//\n// 症狀有多難發現:畫面完全正常——樹畫得出來、每個數字都對、也沒有任何 console 錯誤,\n// **只是那句解釋安靜地不見了**。而那句解釋正是 leo 要這個畫面回答的唯一那件事:\n// 「不上傳通常是不支援,比如程式碼、不支援的格式。」\n// 單元測試(雲端那半)全綠、`curl` 也看不出來(它不執行 JS)。\n//\n// ⇒ 所以這裡測的不是「gapWhy 會不會算數」,而是**它跟呼叫端講不講同一種話**。\n// 以後誰改了任一邊的鍵名,這支就會紅。\n\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\n// 抽出 gapWhy + rollupTree 兩支(錨定「函式結束」這個結構,不綁文案——\n// 同 os-split.test.mjs 2026-08-05 的教訓:綁文案會讓改字就炸)。\nconst start = html.indexOf('function gapWhy');\nconst rollupAt = html.indexOf('function rollupTree', start);\nconst endMark = 'return { sums: sums, kids: kids };';\nconst end = rollupAt < 0 ? -1 : html.indexOf(endMark, rollupAt) + endMark.length + '\\n }'.length;\nif (start < 0 || rollupAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst api = new Function(src + '; return { gapWhy: gapWhy, rollupTree: rollupTree };')();\n\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// 小幫手真的會送上來的形狀(長鍵名),刻意混著支援/不支援/整棵被剪掉的目錄。\nconst nodes = [\n { path: '', name: 'kb', parent: '-', depth: 0, total_files: 2, synced_files: 1, pending_files: 1, unsupported_files: 0, excluded_files: 0 },\n { path: 'docs', name: 'docs', parent: '', depth: 1, total_files: 5, synced_files: 2, pending_files: 1, unsupported_files: 2, excluded_files: 0 },\n { path: 'docs/img', name: 'img', parent: 'docs', depth: 2, total_files: 4, synced_files: 0, pending_files: 0, unsupported_files: 4, excluded_files: 0 },\n { path: 'src', name: 'src', parent: '', depth: 1, total_files: 4, synced_files: 0, pending_files: 0, unsupported_files: 0, excluded_files: 4 },\n { path: 'node_modules', name: 'node_modules', parent: '', depth: 1, total_files: 0, synced_files: 0, pending_files: 0, unsupported_files: 0, excluded_files: 0, skipped: true, skip_reason: '這是工具產生的' },\n];\n\nconst r = api.rollupTree(nodes);\n\n// ① 子樹合計:根要把 docs/img/src 全部疊進來(skipped 的那棵不算——沒走進去就是不知道)\nconst root = r.sums[''];\nchk('根的合計=2+5+4+4(skipped 的 node_modules 不計)', root.total === 15, JSON.stringify(root));\nchk('根的已同步=1+2', root.synced === 3, JSON.stringify(root));\n\n// ② 🔴 本檔的重點:gapWhy 吃得下 rollupTree 吐出來的東西\nconst why = api.gapWhy(root);\nchk('gapWhy(合計) 不是空字串(鍵名對得上)', why.length > 0, JSON.stringify(why));\nchk('gapWhy 講得出不支援的份數', why.includes('6 份'), why); // 2(docs) + 4(img)\nchk('gapWhy 講得出不收的份數', why.includes('4 份'), why); // src\nchk('gapWhy 講得出處理中的份數', why.includes('2 份'), why); // 根 1 + docs 1\n\n// ③ 差額必須解釋得了(leo 的規格:兩個數字不相等是正常的,但差額要說得出來)\nconst gap = root.total - root.synced;\nchk('差額 == 不支援 + 不收 + 處理中', gap === root.unsupported + root.excluded + root.pending,\n `gap=${gap} unsup=${root.unsupported} excl=${root.excluded} pend=${root.pending}`);\n\n// ④ 完全同步的資料夾不該多嘴\nchk('沒有差額時 gapWhy 回空字串',\n api.gapWhy({ total: 3, synced: 3, pending: 0, unsupported: 0, excluded: 0 }) === '');\n\nconsole.log(`\\n${pass} passed, ${fail} failed`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Portal\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n\n
        \n
        \n
        \n
        \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
        \n
        知識入口・Portal
        \n
        \n
        \n \n \n \n
        \n
        \n \n \n
        \n \n \n
        \n
        \n \n
        \n
        \n\n\n
        \n
        \n
        \n
        設定新密碼
        \n
        \n
        \n
        \n \n
        \n
        \n
        \n\n\n
        \n
        \n
        \n
        \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
        \n
        歡迎!先建立你的帳號
        \n
        \n
        \n \n \n \n \n
        \n \n \n
        \n
        這組帳密就是之後登入知識庫用的,只需要設定這一次。
        \n
        \n
        \n\n\n
        \n
        \n
        \"arcrun\"\"arcrun\"
        \n \n
        App 界面
        \n
        App 市集
        \n
        AR 設定
        \n
        App 開發
        \n
        ☾ 切深色
        \n
        \n
        \n
        \n\n \n
        \n
        App 界面
        \n
        載入中…
        \n
        \n\n \n
        \n
        搜尋
        \n
        \n
        \n \n \n
        \n
        \n \n \n \n
        \n \n
        \n
        AI 問答・答案附出處,可回搜尋驗證
        \n
        \n \n \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n\n \n
        \n
        總圖
        \n
        \n
        \n
        \n 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版\n (給 AI 注入用的總庫目錄)。\n
        \n
        \n
        \n\n \n
        \n
        \n \n 知識卡片\n
        \n
        載入中…
        \n
        \n\n \n
        \n
        App 市集瀏覽、安裝、或上傳你自己做的 App
        \n
        \n
        \n
        \n
        \n
        上傳你的 App
        \n
        打包成一份宣告,讓其他人也能安裝——尚未接後端,先佔位
        \n
        \n
        \n
        已安裝
        \n
        載入中…
        \n
        可安裝
        \n \n
        載入中…
        \n
        \n
        \n\n \n
        \n
        上傳Markdown / 純文字
        \n
        \n 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。\n
        \n
        \n
        把 .md / .txt 檔案拖放到這裡
        \n
        \n \n \n
        \n
        \n
        \n\n \n \n
        \n
        App 開發Workflows/零件/Recipe/Credential
        \n
        \n
        \n
        \n
        \n
        叫 AI 幫你做一個 App
        \n
        用一句話描述你要什麼——尚未接後端,先佔位
        \n
        \n
        \n
        工作環境
        \n
        \n
        \n Workflows\n
        \n
        \n 資料上傳\n
        \n
        零件尚未接進 Portal
        \n
        Recipe尚未接進 Portal
        \n
        Credential尚未接進 Portal
        \n
        \n
        \n
        \n\n
        \n
        工作流唯讀・系統狀態
        \n
        \n 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。\n
        \n
        載入中…
        \n
        \n\n \n
        \n
        \n \n App\n \n
        \n
        載入中…
        \n
        \n\n \n
        \n
        設定
        \n \n
        \n
        帳號
        \n
        管理
        \n
        \n
        \n \n
        \n
        \n
        \n
        \n 版本\n \n
        \n
        查詢中…
        \n
        \n \n
        \n
        \n
        \n
        我的帳號
        \n
        載入中…
        \n
        \n
        \n
        \n
        \n
        深色模式
        \n
        預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
        \n
        \n
        \n
        \n
        \n \n
        \n
        更改密碼
        \n
        需輸入舊密碼驗證身分;新密碼至少 8 碼
        \n
        \n \n \n \n \n
        \n
        \n
        \n \n
        \n \n
        \n 你的知識庫網址(小幫手連線用)\n \n \n
        \n
        同步小幫手
        \n
        把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。
        \n
        \n 下載 Mac 版\n
        封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
        \n
        \n
        \n \n
        \n
        \n 你的 MCP 網址(給你的 AI 連線用)\n \n \n
        \n
        接上你的 AI(MCP)
        \n
        把上面這串網址貼到 Claude、ChatGPT 等 AI 的「新增自訂連接器」欄位,就能讓你的 AI 直接查這個知識庫。
        \n
        \n \n
        \n
        AI 設定
        \n
        \n 這裡不需要任何設定。聊天問答用的是你自己 Cloudflare 帳號內建的 AI,裝好就能直接問。
        \n 文件整理成知識卡的部分,請在同步小幫手(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。\n
        \n
        \n \n
        \n
        疑難排解
        \n
        要回報問題,請到你電腦上的 Arcrun(同步小幫手)「版本與更新」頁——那裡的「疑難排解」按一下就能匯出完整診斷檔給我們(只有統計數字,不含你的任何文件內容)。
        \n
        \n \n
        \n
        \n\n \n
        \n
        管理帳號與知識庫授權
        \n
        \n
        帳號
        \n
        管理
        \n
        \n\n
        執行紀錄保留期
        \n
        \n
        保留天數
        \n
        執行紀錄是稽核資料,超過保留天數會被每日自動清除;預設 90 天(3 個月),也可設為「不刪除」(企業稽核用途)。
        \n
        \n \n \n \n
        \n
        \n
        \n
        \n\n
        帳號管理
        \n
        \n
        新增同仁帳號
        \n
        建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
        \n
        \n \n \n \n \n
        \n
        \n
        \n
        \n
        載入中…
        \n\n
        庫目錄管理
        \n
        \n 你的知識庫網址(小幫手連線用)\n \n \n
        \n
        \n \n 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。\n
        \n
        載入中…
        \n
        \n\n
        \n
        \n\n\n
        \n
        App 界面
        \n
        App 市集
        \n
        AR 設定
        \n
        App 開發
        \n
        \n\n
        \n\n\n\n"},"/portal/os-split.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)\n//\n// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語\n// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。\n// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。\nconst start = html.indexOf('var DAEMON_BASE_DEFAULT');\nconst hintAt = html.indexOf('function daemonHint', start);\nconst endMark = '\\n }';\nconst end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;\nif (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst cases = [\n ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n ['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'],\n ['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'],\n ['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n];\nlet pass=0, fail=0;\nconst chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} };\n\nfor (const [name, ua] of cases) {\n const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};');\n const api = fn({userAgent: ua}, {});\n const d = api.daemonPick();\n const label = d.sure ? d.pick.label : '(兩個都給)';\n const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url;\n console.log(`\\n[${name}] sure=${d.sure} → ${label}`);\n console.log(` url: ${url}`);\n if (name==='Windows') {\n chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);\n chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));\n }\n if (name==='Mac') {\n // 2026-08-05:Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip\n // ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。\n chk('Mac 給 dmg(不是 zip)', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));\n chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));\n }\n if (name==='iPhone' || name==='Linux') {\n // iPhone 含 \"Mac OS X\" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」\n if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false);\n if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure);\n }\n}\n// 舊 key 相容\nconst fn2 = new Function('navigator','window', src + '; return daemonBase();');\nconsole.log('\\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}}));\nchk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/');\nchk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/');\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/retrieval-tree.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 檢索過程樹(Arcrun#44 第二輪,leo 2026-08-18「希望以樹狀圖來展示」)的資料組裝測試。\n// 只測**樹的資料**,不測它怎麼被畫出來(那在 tree-render.test.mjs),\n// 我們自己要為之負責的是「層級對不對、有沒有把讀過的東西吞掉」。\n// 跑法:node console-ui/public/portal/retrieval-tree.test.mjs\nimport fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0, j = html.indexOf('{', i);\n for (let k = j; k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst src = ['esc', 'srcLocalPath', 'machineLabelMap', 'srcCrumbs', 'retrievalTreeData', 'normalizeTreeNode', 'visibleRows'].map(grab).join('\\n');\nconst fn = new Function(src + '\\nvar SEARCH_ROUTE_LABEL = { graph: \"圖譜命中子庫\", index: \"索引退回選庫\", all: \"全庫讀取\" };'\n + '\\nreturn { srcCrumbs, machineLabelMap, retrievalTreeData, normalizeTreeNode, visibleRows };')();\n\nlet pass = 0, fail = 0;\nconst t = (l, c, e = '') => { c ? (console.log('PASS:', l), pass++) : (console.log('FAIL:', l, e), fail++); };\nconst txt = (n) => String(n.content).replace(/<[^>]*>/g, '');\n\n// 2026-08-18 從 youlin stage 實跑 /portal/data/chat 抓下來的真回應形狀(narrative 真的是空字串)\nconst REAL = {\n route: 'graph', vector_used: false, libraries_considered: 8,\n libraries: [{ library: 'testkb', score: 1.69, via: 'graph', matched_entities: ['Arcrun124驗證卡'] }],\n indexes_read: [\n { library: 'general', narrative: '', selected: false, top_entities: [], triplet_count: 0, entry_count: 100 },\n { library: 'testkb', narrative: '', selected: true, top_entities: ['Arcrun124驗證卡'], triplet_count: 4, entry_count: 9 },\n { library: 'kb', narrative: '', selected: false, top_entities: ['knowledge-base'], triplet_count: 7, entry_count: 16 }\n ],\n pages_read: [{ page: 'Arcrun124驗證卡', library: 'testkb', chars: 231, source: 'kb://test/arcrun-124-verify.md#0' }]\n};\n\n// 🔴 inkstone/Arcrun#170(2026-08-27)改掉這裡好幾條斷言的期望值——\n// 不是測試鬆綁,是行為本身照票的要求換了:\n// 「子庫」「三元組」「索引沒有敘事」是內部詞,low-code 使用者看不懂\n// (leo 原話:「這是在跟誰溝通?」),全部換成使用者看得懂的說法;\n// 而「考慮過但沒讀原文」的收合桶本身也被拿掉——leo:「這張圖的目的是\n// 告訴他相關文件放哪裡……只該在真的有命中的時候出現,而且只畫命中的\n// 那條路徑」,收合桶再怎麼收合,都還是陪葬在畫面上的無關資訊。\n// (總管 2026-08-27 複驗指令:逐條判斷哪些是刻意改掉、哪些是改壞了,\n// 刻意改的要在這裡寫明原因,不准整支刪掉或跳過。)\n\n// ① 層級=leo 畫的那張:搜尋詞 → 命中資料夾 → 資料夾摘要 → 原文片段\nconst tree = fn.retrievalTreeData('Arcrun124驗證卡是什麼?', REAL);\nt('第 1 層是搜尋詞', txt(tree).indexOf('Arcrun124驗證卡是什麼?') === 0, txt(tree));\nconst lib = tree.children[0];\n// 「命中實體」→「找到關鍵字」(#170:使用者看得懂的字,意思不變)。\n// 🔴 同一次順便拿掉了原始相似度分數(`score 1.69`)與內部路由詞(`via: graph`)——\n// 兩個都是「對使用者沒有意義的數字/內部詞」,不服務「相關文件放哪裡」這個目的,\n// 分數沒有刻度、使用者不知道 1.69 是好是壞;拿掉比留著噪音更誠實。\nt('第 2 層是命中資料夾(帶找到的關鍵字,不帶原始分數/內部路由詞)',\n /testkb/.test(txt(lib)) && /找到關鍵字/.test(txt(lib)) && !/1\\.69/.test(txt(lib)) && !/\\bgraph\\b/.test(txt(lib)), txt(lib));\nconst idx = lib.children[0];\n// 「三元組」整個拿掉(使用者看不懂、對他沒意義),「筆條目」→「筆內容」(跟 assemble 的\n// prompt 用語一致,見 workflows/rag-chat.local.yaml 的 catalog 組字)\nt('第 3 層是該資料夾的摘要(不含三元組這種內部詞)', !/三元組/.test(txt(idx)) && /筆內容/.test(txt(idx)), txt(idx));\nconst page = idx.children[0];\nt('第 4 層是原文片段(掛在資料夾摘要底下)', /Arcrun124驗證卡/.test(txt(page)) && /231 字/.test(txt(page)), txt(page));\nt('原文片段帶資料夾麵包屑', /test › arcrun-124-verify\\.md/.test(txt(page)), txt(page));\n\n// ② narrative 是空字串時不假裝有敘事——但也不能講「索引沒有敘事」這種內部詞\n// (使用者看不懂「索引」「敘事」是什麼),改把這個資料夾主要談什麼(top_entities)\n// 擺出來,讓這一層仍然說得出讀到了什麼(REAL 的 testkb 剛好兩者都是這個情況:\n// narrative 空、top_entities 有)\nt('narrative 空時用「主要談」代替,不講內部詞「索引沒有敘事」', /主要談:Arcrun124驗證卡/.test(txt(idx)) && !/索引沒有敘事/.test(txt(idx)), txt(idx));\n// narrative 與 top_entities 都沒有時,這一層真的沒有東西可說——整行不顯示,\n// 不留一個「(索引沒有敘事)」式的空殼占位字(general 那筆兩者皆空,但 general\n// 沒被選用,不會出現在樹上;這裡直接測 idxNode 沒被匯出,改用等價的資料夾建構驗證)\nconst bothEmpty = fn.retrievalTreeData('問句', {\n route: 'graph',\n libraries: [{ library: 'blanklib', score: 1, via: 'graph', matched_entities: [] }],\n indexes_read: [{ library: 'blanklib', narrative: '', selected: true, top_entities: [], entry_count: 3 }],\n pages_read: [{ page: 'Q', library: 'blanklib', chars: 5, source: 'kb://q.md#0' }]\n});\nconst blankIdx = bothEmpty.children[0].children[0];\nt('narrative 與主要談都沒有時,那一行不留內部詞占位字', !/主要談|索引沒有敘事/.test(txt(blankIdx)) && /筆內容/.test(txt(blankIdx)), txt(blankIdx));\n\n// ③ 🔴 拿掉「沒被選用的資料夾收在收合節點」這整條行為(#170):\n// leo 的判準是「只畫命中的那條路徑」,收合起來還是陪葬在畫面上——\n// 使用者要的是「相關文件放哪裡」,不是「這次系統瞄過哪些資料夾」。\n// 新判準:沒被選用、沒讀到原文的資料夾**完全不出現**在樹上。\nt('沒被選用的資料夾完全不出現(不生收合桶,也不用任何形式露出)', !/general|(? 換行),那正是 leo 說的「上下間距很大」。\n// 一列放不下的部分由 CSS 截斷、全文掛 title,所以資料這端不准再自己換行。\nconst everyContent = [tree, lib, idx, page].map(n => String(n.content)).join('');\nt('節點內容不含換行標籤(一個節點=一列)', !//i.test(everyContent),\n (everyContent.match(/]*>/gi) || []).join(' '));\nt('同一列裡的多段資訊用分隔點接起來', //.test(String(page.content)), String(page.content));\n\n// ⑧ 判準第 2 條的算式:列數 × 一列 23px 要塞得進 1080p(收合的那支只算它自己一列)\n// #170 拿掉了「考慮過但沒讀原文」那個收合桶,這棵樹裡不再有任何 fold=1 的節點——\n// 但 visibleRows 的收合行為是共用機制(同一份 renderTree 服務三個入口,見檔頭註解),\n// 用一個手刻的 fixture 驗證它沒有被連帶改壞,不依賴這棵樹恰好長出一個折疊節點。\nconst foldedFixture = { content: 'x', payload: { fold: 1 },\n children: [{ content: 'a', children: [] }, { content: 'b', children: [] }] };\nt('收合的節點只算一列(就算底下藏著子節點)', fn.visibleRows(foldedFixture) === 1, String(fn.visibleRows(foldedFixture)));\n// 新樹形=根(1) → 資料夾(1) → 摘要(1) → 原文片段(1),沒有陪葬分支,共 4 列\n// (改前是 5:多算一列給已拿掉的「考慮過但沒讀原文」收合桶)\nt('整棵樹的列數=展開狀態下看得到幾列', fn.visibleRows(fn.normalizeTreeNode(tree)) === 4,\n String(fn.visibleRows(fn.normalizeTreeNode(tree))));\nt('單一節點也算一列(不會回 0)', fn.visibleRows({ content: 'x', children: [] }) === 1);\n\n// ⑨ 機器那一層(inkstone/mira#6 補上的兩格:machine=比對鍵、machine_label=顯示名)\nconst withM = fn.srcCrumbs('kb://RFP/daemon-真機驗收.md#0', 'mira6-watch',\n { machine: 'youlinhsieh@Leo-MBA', machine_label: '教育部 Leo 的 Mac' });\nt('有機器就拿顯示名', withM.machineLabel === '教育部 Leo 的 Mac', String(withM.machineLabel));\nt('比對鍵原樣留著(樹上分支要用它當 key)', withM.machine === 'youlinhsieh@Leo-MBA', String(withM.machine));\nt('機器名沒有混進路徑', withM.path === 'RFP/daemon-真機驗收.md' && withM.dirs.join('/') === 'RFP', withM.path);\n\nconst onlyKey = fn.srcCrumbs('kb://a/b.md#0', 'lib', { machine: 'u@Host' });\nt('只有比對鍵沒有顯示名 → 顯示名退回比對鍵', onlyKey.machineLabel === 'u@Host', String(onlyKey.machineLabel));\n\nconst noM = fn.srcCrumbs('kb://a/b.md#0', 'lib', {});\nt('舊資料沒有這兩格 → machine 是 null(畫面顯示未知來源)', noM.machine === null && noM.machineLabel === null, JSON.stringify(noM.machine));\nconst emptyM = fn.srcCrumbs('kb://a/b.md#0', 'lib', { machine: '', machine_label: '' });\nt('空字串一律當沒有,不顯示成空白機器', emptyM.machine === null && emptyM.machineLabel === null, JSON.stringify(emptyM));\n\n// 同一個相對路徑來自兩台機器 → 樹上要是兩支,不可以被併成一支\nconst twoMachines = fn.retrievalTreeData('問句', { route: 'graph',\n libraries: [{ library: 'mira6-watch', score: 2.1, via: 'graph', matched_entities: ['x'] }],\n indexes_read: [{ library: 'mira6-watch', narrative: '', selected: true, triplet_count: 1, entry_count: 2 }],\n pages_read: [\n { page: 'P', library: 'mira6-watch', chars: 10, source: 'kb://RFP/same.md#0', machine: 'u@Leo-MBA', machine_label: 'Leo 的 Mac' },\n { page: 'P', library: 'mira6-watch', chars: 10, source: 'kb://RFP/same.md#0', machine: 'u@Leo-iMac', machine_label: 'Leo 的 iMac' } ] });\nconst pageNodes = twoMachines.children[0].children[0].children;\nt('同路徑不同機器=兩支,不被併掉', pageNodes.length === 2, String(pageNodes.length));\nt('兩支各自標出自己的機器',\n /Leo 的 Mac/.test(pageNodes[0].content) && /Leo 的 iMac/.test(pageNodes[1].content),\n txt(pageNodes[0]) + ' / ' + txt(pageNodes[1]));\nconst noMachinePage = fn.retrievalTreeData('問句', { route: 'graph', libraries: [], indexes_read: [],\n pages_read: [{ page: 'P', library: 'kb', chars: 1, source: 'kb://a.md#0' }] });\nt('沒有機器的片段標「未知來源」,不留空', /未知來源/.test(noMachinePage.children[0].children[0].content));\n\n// ⑩ 同一台機器只能有一個顯示名(youlin stage 2026-08-18 實測到的真實情況:\n// 同一把 youlinhsieh@Leo-MBA,改名前那筆 label 就是 ID 本身,改名後是「教育部 Leo 的 Mac」)\nconst REALPAIR = [\n { page: '資料夾總覽:mira6-watch', library: 'mira6-watch', chars: 40, source: 'kb://x.md#0',\n machine: 'youlinhsieh@Leo-MBA', machine_label: 'youlinhsieh@Leo-MBA' },\n { page: 'daemon-真機驗收', library: 'mira6-watch', chars: 93, source: 'kb://RFP/daemon.md#0',\n machine: 'youlinhsieh@Leo-MBA', machine_label: '教育部 Leo 的 Mac' }\n];\nconst lb = fn.machineLabelMap(REALPAIR);\nt('有人取過名字就全篇用那個名字', lb['youlinhsieh@Leo-MBA'] === '教育部 Leo 的 Mac', JSON.stringify(lb));\nt('舊那筆也跟著顯示成新名字(同一台不會長成兩台)',\n fn.srcCrumbs('kb://x.md#0', 'mira6-watch', REALPAIR[0], lb).machineLabel === '教育部 Leo 的 Mac');\nt('比對鍵不受顯示名影響', fn.srcCrumbs('kb://x.md#0', 'mira6-watch', REALPAIR[0], lb).machine === 'youlinhsieh@Leo-MBA');\nt('誰都沒取過名字 → 退回顯示 ID', fn.machineLabelMap([{ machine: 'u@H', machine_label: 'u@H' }])['u@H'] === 'u@H');\nt('兩台不同機器不會被併成一個名字',\n Object.keys(fn.machineLabelMap([{ machine: 'a@X', machine_label: 'X 機' }, { machine: 'b@Y', machine_label: 'Y 機' }])).length === 2);\n\nconst treeReal = fn.retrievalTreeData('問句', { route: 'graph',\n libraries: [{ library: 'mira6-watch', score: 2.1, via: 'graph', matched_entities: ['x'] }],\n indexes_read: [{ library: 'mira6-watch', narrative: '', selected: true, triplet_count: 1, entry_count: 2 }],\n pages_read: REALPAIR });\nconst both = treeReal.children[0].children[0].children.map(n => n.content).join(' ');\nt('樹上兩個片段標的是同一個機器名', (both.match(/教育部 Leo 的 Mac/g) || []).length === 2, both.slice(0, 200));\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/safejson.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n\n// 抽出 safeJson 與 friendlyErr 求值\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d=0, j=html.indexOf('{', i);\n for (let k=j;k{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)};\n\n// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁)\nconst html404 = '404 Not Found';\nawait fn.safeJson({ text: () => Promise.resolve(html404) })\n .then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null))\n .catch(e => t('404 HTML → 不該拋錯', false, e.message));\n\nawait fn.safeJson({ text: () => Promise.resolve('') })\n .then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}'))\n .catch(() => t('空回應 → 不該拋錯', false));\n\nawait fn.safeJson({ text: () => Promise.resolve('{\"error\":\"帳號或密碼不對\"}') })\n .then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), )\n .catch(() => t('正常 JSON 不該拋錯', false));\n\n// ② friendlyErr:不可把技術訊息噴給使用者\nconst leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4'));\nt('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`);\nt('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`);\n\nconst net = fn.friendlyErr(new Error('Failed to fetch'));\nt('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`);\n\nconst ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組'));\nt('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`);\n\nconst stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined'));\nt('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`);\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/tree-render.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 樹的**畫法**測試(Arcrun#144,leo 2026-08-19 打回 markmap 之後)。\n//\n// 取代舊的 tree-links.test.mjs——那支測的是「把 markmap 的貝茲曲線改寫成 L 型折線」,\n// 而那整段程式碼連同 markmap 一起被移除了(形狀錯的不是線,是佈局;見 index.html\n// renderTree() 的技術選型註解)。\n//\n// 這支守的是**我們自己寫的那一段**:把樹資料攤成巢狀
        的字串拼接。\n// 摺疊行為本身是瀏覽器的,不測;縮排是 CSS 的,不測。\n// 跑法:node console-ui/public/portal/tree-render.test.mjs\nimport fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0;\n for (let k = html.indexOf('{', i); k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst fn = new Function(\n ['esc', 'treeHtml', 'treeKidsHtml', 'treeNodeHtml', 'titleAttr', 'normalizeTreeNode', 'visibleRows',\n 'gapWhy', 'rollupTree', 'folderTreeData', 'adminLibsTreeData', 'libTreeNode', 'folderKidsData'].map(grab).join('\\n')\n // 同步庫總覽的第四層是「點開才抓」的,資料放在這兩張表裡(畫面端的快取)。\n // 測試自己當那份快取,就能把「還沒抓/抓過但沒有/抓到了」三種狀態都走一遍。\n + '\\nvar folderTrees = {}, treeOpen = {};'\n + '\\nreturn { treeHtml, treeKidsHtml, treeNodeHtml, titleAttr, normalizeTreeNode, visibleRows,'\n + ' adminLibsTreeData, folderTreeData,'\n + ' setFolderTree: function (n, t) { folderTrees[n] = t; },'\n + ' setOpen: function (n, v) { treeOpen[n] = v; } };')();\n\nlet pass = 0, fail = 0;\nconst t = (l, c, e = '') => { c ? (console.log('PASS:', l), pass++) : (console.log('FAIL:', l, e), fail++); };\n\nconst leaf = (c) => ({ content: c, children: [] });\nconst TREE = {\n content: '問句', children: [\n { content: 'kb', children: [leaf('頁 A'), leaf('頁 B')] },\n { content: '考慮過但沒讀原文', payload: { fold: 1 }, children: [leaf('other')] }\n ]\n};\n\n// ① 有小孩=可摺疊的
        ;沒小孩=一列,不生假的可展開節點\nconst out = fn.treeHtml(TREE);\nt('外層是 .ftree', out.startsWith('
        '), out.slice(0, 40));\nt('有小孩的節點是
        ', (out.match(/
        ,是一列 .ft-leaf', (out.match(/ft-row ft-leaf/g) || []).length === 3,\n String((out.match(/ft-row ft-leaf/g) || []).length));\nt('可摺疊的節點把標題放在 (點得到的就是這一列)', //.test(out));\n\n// ② 縮排靠巢狀,不靠算 padding ⇒ 深度沒有上限\nt('每一層小孩包在 .ft-kids 裡', (out.match(/
        /g) || []).length === 3,\n String((out.match(/
        /g) || []).length));\nlet deep = leaf('底'); for (let i = 0; i < 12; i++) deep = { content: 'L' + i, children: [deep] };\nt('12 層照樣攤得開(沒有寫死的深度上限)',\n (fn.treeHtml(deep).match(/
        /g) || []).length === 12);\n\n// ③ payload.fold ⇒ 預設收起來(那一支不能被展開,也不能被吞掉)\nconst folded = out.slice(out.indexOf('考慮過') - 200, out.indexOf('考慮過'));\nt('fold 的那一支沒有 open 屬性', /
        /.test(out), '');\nt('收起來的那支內容仍在 DOM 裡(不隱瞞,只是收合)', /other/.test(out));\n\n// ④ 節點內容是 HTML(三個入口的異質節點靠這個分)——不可以被跳脫掉\nt('節點的 HTML 原樣留著', /kb<\\/b>/.test(out) && /頁 A<\\/b>/.test(out));\n\n// ⑤ 一列放不下會截斷 ⇒ 全文必須掛在 title 上(=檔案總管對長檔名的做法)\nt('title 是剝掉標籤的純文字', fn.titleAttr('·乙') === ' title=\"甲·乙\"',\n fn.titleAttr('·乙'));\nt('title 裡的引號要跳脫,不能把屬性打斷', /"|"|'/.test(fn.titleAttr('say \"hi\"')) || !/\"hi\"/.test(fn.titleAttr('say \"hi\"')),\n fn.titleAttr('say \"hi\"'));\nt('沒有文字就不掛空的 title', fn.titleAttr('') === '');\n\n// ⑥ 多個根(資料夾樹會有)要全部畫出來,不是只畫第一個\nconst multi = fn.treeHtml([leaf('根一'), leaf('根二')]);\nt('陣列=多個根,全部畫出來', /根一/.test(multi) && /根二/.test(multi));\n\n// ⑦ 註腳(資料夾樹的「數字是已同步/總共」那段)掛得上去,且只在有內容時出現\nt('有註腳就畫在樹的後面', /
        說明<\\/div><\\/div>$/.test(fn.treeHtml(leaf('x'), '說明')));\nt('沒註腳就不生空的區塊', !/ft-foot/.test(fn.treeHtml(leaf('x'))));\n\n// ⑧ 呼叫端漏寫 children 不會炸(葉子寫成 { content } 是最常見的手滑)\nt('缺 children 的節點補得回來', fn.normalizeTreeNode({ content: 'x' }).children.length === 0);\nt('缺 children 也畫得出來', /ft-leaf/.test(fn.treeHtml({ content: 'x' })));\n\n// ⑨ 🔴 判準第 2 條的算式:列數 × 23px 要塞得進 1080p。\n// 這支測的是「列數算得對」——收合的那支只算它自己一列。\nt('列數=展開狀態下看得到的列', fn.visibleRows(fn.normalizeTreeNode(TREE)) === 5,\n String(fn.visibleRows(fn.normalizeTreeNode(TREE)))); // 根 + kb + 2 頁 + 收合的那支\nconst wide = { content: 'r', children: [] };\nfor (let i = 0; i < 8; i++) wide.children.push({ content: 'lib' + i, children: [{ content: 'idx', children: Array.from({ length: 2 }, () => leaf('p')) }] });\nt('8 子庫 × (索引+2 片原文) = 33 列,×23px ≈ 759px,1080p 放得下',\n fn.visibleRows(fn.normalizeTreeNode(wide)) === 33 && fn.visibleRows(fn.normalizeTreeNode(wide)) * 23 < 900,\n String(fn.visibleRows(fn.normalizeTreeNode(wide))));\n\n// ⑩ 行尾那一格(庫目錄的「移除」鈕)——名字被截斷時它不能跟著消失,\n// 所以它必須在 .ft-lbl **外面**,而且不出現在 title 裡(title 是「這一列叫什麼」)。\nconst withTail = fn.treeHtml({ content: '很長的名字', tail: '', children: [] });\nt('tail 畫在 .ft-lbl 外面(不參與截斷)', /<\\/span>