From c662af7e2a3298d3a22d49ea1b2208f27b046a5e Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 24 Jul 2026 15:38:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(portal):=20=E6=BA=AF=E6=BA=90=E9=80=A3?= =?UTF-8?q?=E7=B5=90=E5=85=BC=E5=AE=B9=20kb://=20=E8=88=87=E5=85=A9?= =?UTF-8?q?=E7=A8=AE=20gitea=20scheme=EF=BC=88daemon-beta=20t21=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新 ingest 鏈(rag_ingest_card/rag_ingest_direct)寫的 metadata source 是 kb://,但 portal 溯源只認 gitea://——B4 部好溯源後一經重灌即再斷鏈。 - gitea://(舊 rag_ingest v2):{base}/,行為一字不變(不回歸) - gitea:@(km-wiki-ingest 實形): {base}//src/branch/main/,#segNN 錨點捨棄 - kb://(新 ingest 鏈實形):雲端沒有對應網頁,不造死鏈—— 卡片詳頁顯示本地路徑文字+「在你的同步資料夾內」標註(沿用既有 sourceWebBase 未設時的純文字降級樣式);AI 出處列同標註 - 測試:console-ui/tests/portal-source-href.test.mjs(node --test, 抽真身 srcHref/srcLocalPath 求值,6 case 全綠);package.json 加 test script - public/portal/index.html 用 ARCRUN_API_BASE=https://cypher.arcrun.dev 重 build(與既有產物同參數,diff 僅本次 scheme 改動) 實形出處:kb:// = arcrun-rag workflows/rag-ingest.yaml:115('kb://'+path); gitea:@ = registry/examples/km-wiki-ingest/workflow.yaml:276。 Co-Authored-By: Claude Fable 5 --- console-ui/package.json | 1 + console-ui/public/portal/index.html | 48 +++++++-- console-ui/src/portal-ui.ts | 48 +++++++-- console-ui/tests/portal-source-href.test.mjs | 108 +++++++++++++++++++ 4 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 console-ui/tests/portal-source-href.test.mjs diff --git a/console-ui/package.json b/console-ui/package.json index 720adf3..8eadf56 100644 --- a/console-ui/package.json +++ b/console-ui/package.json @@ -5,6 +5,7 @@ "description": "Arcrun Console / Portal 靜態前端(Cloudflare Pages)——從 cypher-executor 搬出的 UI 層", "scripts": { "build": "node scripts/build.mjs", + "test": "node --test 'tests/*.test.mjs'", "deploy": "node scripts/deploy.mjs", "deploy:personal": "node scripts/deploy.mjs personal", "deploy:enterprise": "node scripts/deploy.mjs enterprise", diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index c207785..dce6d89 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -478,13 +478,35 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { try { var m = JSON.parse(e.metadata_json || 'null'); return m && typeof m === 'object' ? m : {}; } catch (er) { return {}; } } function entrySource(e) { var m = entryMeta(e); return typeof m.source === 'string' ? m.source : ''; } - // 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設且 source 是 gitea:// 才給 href; - // 其餘回空字串=維持純文字(行為與未設時一字不變)。chunk 錨點(#n)捨棄。 + // 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設才給 href。認兩種 gitea scheme(t21): + // gitea:// → {base}/(base 已含 repo 瀏覽前綴;舊 rag_ingest v2,行為不變) + // gitea:@ → {base}//src/branch/main/(km-wiki-ingest;base=Gitea 站根) + // kb://=用戶自己同步資料夾內的相對路徑,雲端沒有對應網頁 → 回空字串不造死鏈; + // 顯示交給 srcLocalPath()(誠實標「在你的同步資料夾內」)。其餘 scheme 回空=維持純文字。 + // chunk/segment 錨點(#n / #segNN)捨棄——Gitea 網頁沒有對應錨。 var SOURCE_WEB_BASE = ""; + function encSrcPath(p) { return p.split('/').map(encodeURIComponent).join('/'); } function srcHref(src) { - if (!SOURCE_WEB_BASE || String(src).indexOf('gitea://') !== 0) return ''; - var p = String(src).slice(8).replace(/#\d+$/, ''); - return SOURCE_WEB_BASE.replace(/\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/'); + src = String(src); + if (!SOURCE_WEB_BASE) return ''; + var base = SOURCE_WEB_BASE.replace(/\/+$/, ''); + if (src.indexOf('gitea://') === 0) { + var p = src.slice(8).replace(/#\d+$/, ''); + return base + '/' + encSrcPath(p); + } + if (src.indexOf('gitea:') === 0) { + var rest = src.slice(6).replace(/#[^#]*$/, ''); + var at = rest.indexOf('@'); + if (at <= 0 || at >= rest.length - 1) return ''; + return base + '/' + encSrcPath(rest.slice(0, at)) + '/src/branch/main/' + encSrcPath(rest.slice(at + 1)); + } + return ''; + } + // kb:// 來源的本地相對路徑(去 scheme、去錨點);非 kb:// 回空字串。 + function srcLocalPath(src) { + src = String(src); + if (src.indexOf('kb://') !== 0) return ''; + return src.slice(5).replace(/#[^#]*$/, ''); } function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; } function entryTitle(e) { @@ -713,10 +735,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var page = (s && (s.page || s.page_name)) || ''; var mode = (s && s.mode) || ''; var href = s && s.source ? srcHref(s.source) : ''; + var localPath = s && s.source ? srcLocalPath(s.source) : ''; return '
' + (mode ? '' + esc(mode) + '' : '') + '' + esc(page || '(無頁名)') + '' + (href ? '來源 ↗' : '') + + (!href && localPath ? '在你的同步資料夾內' : '') + '
'; }).join('') + ''; } @@ -822,9 +846,17 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { (src ? (function () { var href = srcHref(src); - var body = href - ? '' + esc(src) + ' ↗' - : '' + esc(src) + ''; + var localPath = srcLocalPath(src); + var body; + if (href) { + body = '' + esc(src) + ' ↗'; + } else if (localPath) { + // kb://=本地同步資料夾的相對路徑,雲端沒有對應網頁——誠實顯示路徑文字+標註,不造死鏈。 + body = '' + esc(localPath) + '' + + '在你的同步資料夾內'; + } else { + body = '' + esc(src) + ''; + } return '
來源回溯' + body + '
'; })() : ''); diff --git a/console-ui/src/portal-ui.ts b/console-ui/src/portal-ui.ts index 74f7970..c6fcbac 100644 --- a/console-ui/src/portal-ui.ts +++ b/console-ui/src/portal-ui.ts @@ -504,13 +504,35 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string { try { var m = JSON.parse(e.metadata_json || 'null'); return m && typeof m === 'object' ? m : {}; } catch (er) { return {}; } } function entrySource(e) { var m = entryMeta(e); return typeof m.source === 'string' ? m.source : ''; } - // 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設且 source 是 gitea:// 才給 href; - // 其餘回空字串=維持純文字(行為與未設時一字不變)。chunk 錨點(#n)捨棄。 + // 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設才給 href。認兩種 gitea scheme(t21): + // gitea:// → {base}/(base 已含 repo 瀏覽前綴;舊 rag_ingest v2,行為不變) + // gitea:@ → {base}//src/branch/main/(km-wiki-ingest;base=Gitea 站根) + // kb://=用戶自己同步資料夾內的相對路徑,雲端沒有對應網頁 → 回空字串不造死鏈; + // 顯示交給 srcLocalPath()(誠實標「在你的同步資料夾內」)。其餘 scheme 回空=維持純文字。 + // chunk/segment 錨點(#n / #segNN)捨棄——Gitea 網頁沒有對應錨。 var SOURCE_WEB_BASE = ${JSON.stringify(sourceWebBase)}; + function encSrcPath(p) { return p.split('/').map(encodeURIComponent).join('/'); } function srcHref(src) { - if (!SOURCE_WEB_BASE || String(src).indexOf('gitea://') !== 0) return ''; - var p = String(src).slice(8).replace(/#\\d+$/, ''); - return SOURCE_WEB_BASE.replace(/\\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/'); + src = String(src); + if (!SOURCE_WEB_BASE) return ''; + var base = SOURCE_WEB_BASE.replace(/\\/+$/, ''); + if (src.indexOf('gitea://') === 0) { + var p = src.slice(8).replace(/#\\d+$/, ''); + return base + '/' + encSrcPath(p); + } + if (src.indexOf('gitea:') === 0) { + var rest = src.slice(6).replace(/#[^#]*$/, ''); + var at = rest.indexOf('@'); + if (at <= 0 || at >= rest.length - 1) return ''; + return base + '/' + encSrcPath(rest.slice(0, at)) + '/src/branch/main/' + encSrcPath(rest.slice(at + 1)); + } + return ''; + } + // kb:// 來源的本地相對路徑(去 scheme、去錨點);非 kb:// 回空字串。 + function srcLocalPath(src) { + src = String(src); + if (src.indexOf('kb://') !== 0) return ''; + return src.slice(5).replace(/#[^#]*$/, ''); } function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; } function entryTitle(e) { @@ -739,10 +761,12 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string { var page = (s && (s.page || s.page_name)) || ''; var mode = (s && s.mode) || ''; var href = s && s.source ? srcHref(s.source) : ''; + var localPath = s && s.source ? srcLocalPath(s.source) : ''; return '
' + (mode ? '' + esc(mode) + '' : '') + '' + esc(page || '(無頁名)') + '' + (href ? '來源 ↗' : '') + + (!href && localPath ? '在你的同步資料夾內' : '') + '
'; }).join('') + ''; } @@ -848,9 +872,17 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string { (src ? (function () { var href = srcHref(src); - var body = href - ? '' + esc(src) + ' ↗' - : '' + esc(src) + ''; + var localPath = srcLocalPath(src); + var body; + if (href) { + body = '' + esc(src) + ' ↗'; + } else if (localPath) { + // kb://=本地同步資料夾的相對路徑,雲端沒有對應網頁——誠實顯示路徑文字+標註,不造死鏈。 + body = '' + esc(localPath) + '' + + '在你的同步資料夾內'; + } else { + body = '' + esc(src) + ''; + } return '
來源回溯' + body + '
'; })() : ''); diff --git a/console-ui/tests/portal-source-href.test.mjs b/console-ui/tests/portal-source-href.test.mjs new file mode 100644 index 0000000..116c02c --- /dev/null +++ b/console-ui/tests/portal-source-href.test.mjs @@ -0,0 +1,108 @@ +/** + * Portal 溯源 scheme 測試(daemon-beta t21 — kb:// vs gitea:// 斷鏈地雷) + * + * 驗的是 src/portal-ui.ts 內嵌客戶端 JS 的 srcHref / srcLocalPath: + * gitea:// → {base}/(舊 rag_ingest v2 實形,行為不回歸) + * gitea:@ → {base}//src/branch/main/(km-wiki-ingest 實形, + * 見 registry/examples/km-wiki-ingest/workflow.yaml `gitea:${repo}@${relPath}`) + * kb:// → 不造連結(雲端無對應網頁),srcLocalPath 給本地路徑文字, + * UI 標「在你的同步資料夾內」(arcrun-rag rag-ingest.yaml `'kb://' + path`) + * + * 測法:從 portal-ui.ts 的 template literal 抽出三個函式原始碼(brace 平衡掃描), + * 還原 template 跳脫(`\\` → `\`)後用 new Function 求值——測的就是真正會被 + * build 進 public/portal/index.html 的那份 JS,零外部依賴(console-ui 哲學)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = readFileSync(join(HERE, '..', 'src', 'portal-ui.ts'), 'utf8'); + +/** 從原始碼抽出 `function (...) { ... }` 全文(brace 平衡;函式體無字串內大括號)。 */ +function extractFn(name) { + const start = SRC.indexOf(`function ${name}(`); + assert.ok(start >= 0, `portal-ui.ts 找不到 function ${name}`); + const braceStart = SRC.indexOf('{', SRC.indexOf(')', start)); + let i = braceStart + 1; + let depth = 1; + while (i < SRC.length && depth > 0) { + if (SRC[i] === '{') depth++; + else if (SRC[i] === '}') depth--; + i++; + } + // template literal 內的 `\\` 在產出 HTML 時是 `\`——還原成瀏覽器實際跑的 JS + return SRC.slice(start, i).replace(/\\\\/g, '\\'); +} + +/** 用指定 SOURCE_WEB_BASE 建出真正的 srcHref / srcLocalPath。 */ +function makeFns(base) { + const code = [extractFn('encSrcPath'), extractFn('srcHref'), extractFn('srcLocalPath')].join('\n'); + return new Function('SOURCE_WEB_BASE', `${code}\nreturn { srcHref, srcLocalPath };`)(base); +} + +const BASE = 'https://gitea.example.com'; + +test('gitea://(舊 rag_ingest v2)— 現行為不回歸:base + path、#n 錨點捨棄', () => { + const { srcHref } = makeFns(BASE + '/'); // 尾斜線要被吃掉 + assert.equal(srcHref('gitea://system-dev/wiki/foo.md'), `${BASE}/system-dev/wiki/foo.md`); + assert.equal(srcHref('gitea://system-dev/wiki/foo.md#3'), `${BASE}/system-dev/wiki/foo.md`); + // 中文路徑逐段 encode + assert.equal(srcHref('gitea://docs/員工手冊.md'), `${BASE}/docs/${encodeURIComponent('員工手冊.md')}`); +}); + +test('gitea:@(km-wiki-ingest)— base//src/branch/main/', () => { + const { srcHref } = makeFns(BASE); + assert.equal( + srcHref('gitea:Leo/notes@system-dev/wiki/cards/foo.md'), + `${BASE}/Leo/notes/src/branch/main/system-dev/wiki/cards/foo.md` + ); + // 分段錨點(#segNN)捨棄 + assert.equal( + srcHref('gitea:Leo/notes@wiki/foo.md#seg02'), + `${BASE}/Leo/notes/src/branch/main/wiki/foo.md` + ); + // 中文路徑逐段 encode + assert.equal( + srcHref('gitea:Leo/notes@docs/員工手冊.md'), + `${BASE}/Leo/notes/src/branch/main/docs/${encodeURIComponent('員工手冊.md')}` + ); + // 缺 @ 或畸形 → 不造連結 + assert.equal(srcHref('gitea:Leo/notes'), ''); + assert.equal(srcHref('gitea:@wiki/foo.md'), ''); + assert.equal(srcHref('gitea:Leo/notes@'), ''); +}); + +test('kb://(新 ingest 鏈)— 不造死鏈、srcLocalPath 給本地路徑', () => { + const { srcHref, srcLocalPath } = makeFns(BASE); + assert.equal(srcHref('kb://人事/員工特休假規定.md'), ''); + assert.equal(srcLocalPath('kb://人事/員工特休假規定.md'), '人事/員工特休假規定.md'); + assert.equal(srcLocalPath('kb://foo.md#seg01'), 'foo.md'); + // 非 kb:// 不給本地路徑 + assert.equal(srcLocalPath('gitea://foo.md'), ''); + assert.equal(srcLocalPath('gitea:Leo/notes@foo.md'), ''); + assert.equal(srcLocalPath('https://example.com/x'), ''); +}); + +test('SOURCE_WEB_BASE 未設 — 全部 scheme 都不給 href(降級純文字),kb:// 顯示不受影響', () => { + const { srcHref, srcLocalPath } = makeFns(''); + assert.equal(srcHref('gitea://wiki/foo.md'), ''); + assert.equal(srcHref('gitea:Leo/notes@wiki/foo.md'), ''); + assert.equal(srcHref('kb://foo.md'), ''); + assert.equal(srcLocalPath('kb://foo.md'), 'foo.md'); // 本地路徑與 base 無關 +}); + +test('未知 scheme — 誠實維持純文字(不猜連結)', () => { + const { srcHref, srcLocalPath } = makeFns(BASE); + assert.equal(srcHref('https://example.com/foo'), ''); + assert.equal(srcHref('random-string'), ''); + assert.equal(srcLocalPath('random-string'), ''); +}); + +test('UI 有 kb:// 的誠實標註(兩個顯示點:卡片詳頁 srcline + AI 出處列)', () => { + const hits = SRC.split('在你的同步資料夾內').length - 1; + assert.ok(hits >= 2, `「在你的同步資料夾內」標註應出現在 ≥2 個顯示點,實得 ${hits}`); + assert.ok(SRC.includes('srcLocalPath(src)'), '卡片詳頁應走 srcLocalPath 降級'); +});