fix(portal): 溯源連結兼容 kb:// 與兩種 gitea scheme(daemon-beta t21)

新 ingest 鏈(rag_ingest_card/rag_ingest_direct)寫的 metadata source 是
kb://<path>,但 portal 溯源只認 gitea://——B4 部好溯源後一經重灌即再斷鏈。

- gitea://<path>(舊 rag_ingest v2):{base}/<path>,行為一字不變(不回歸)
- gitea:<org/repo>@<path>(km-wiki-ingest 實形):
  {base}/<org/repo>/src/branch/main/<path>,#segNN 錨點捨棄
- kb://<path>(新 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 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-24 15:38:39 +08:00
parent ab8e07201f
commit c662af7e2a
4 changed files with 189 additions and 16 deletions
+1
View File
@@ -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",
+40 -8
View File
@@ -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 schemet21):
// gitea://<path> → {base}/<path>base 已含 repo 瀏覽前綴;舊 rag_ingest v2,行為不變)
// gitea:<org/repo>@<path> → {base}/<org/repo>/src/branch/main/<path>km-wiki-ingestbaseGitea 站根)
// kb://<path>=用戶自己同步資料夾內的相對路徑,雲端沒有對應網頁 → 回空字串不造死鏈;
// 顯示交給 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 '<div class="nbrow" data-aisrc="' + esc(page) + '">' +
(mode ? '<span class="tag" style="flex:none">' + esc(mode) + '</span>' : '') +
'<span style="word-break:break-word">' + esc(page || '(無頁名)') + '</span>' +
(href ? '<a href="' + esc(href) + '" target="_blank" rel="noopener" style="flex:none;color:var(--amber);font-size:13px" title="開啟來源檔">來源 ↗</a>' : '') +
(!href && localPath ? '<span class="dim mono" style="flex:none;font-size:12.5px" title="' + esc(localPath) + '">在你的同步資料夾內</span>' : '') +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none"></span></div>';
}).join('') + '</div>';
}
@@ -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
? '<a class="mono" style="font-size:14px;color:var(--amber);word-break:break-all" href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(src) + ' ↗</a>'
: '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(src) + '</span>';
var localPath = srcLocalPath(src);
var body;
if (href) {
body = '<a class="mono" style="font-size:14px;color:var(--amber);word-break:break-all" href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(src) + ' ↗</a>';
} else if (localPath) {
// kb://=本地同步資料夾的相對路徑,雲端沒有對應網頁——誠實顯示路徑文字+標註,不造死鏈。
body = '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(localPath) + '</span>' +
'<span class="dim" style="font-size:12.5px;flex:none">在你的同步資料夾內</span>';
} else {
body = '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(src) + '</span>';
}
return '<div class="srcline"><span style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none">來源回溯</span>' + body + '</div>';
})()
: '');
+40 -8
View File
@@ -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 schemet21):
// gitea://<path> → {base}/<path>base 已含 repo 瀏覽前綴;舊 rag_ingest v2,行為不變)
// gitea:<org/repo>@<path> → {base}/<org/repo>/src/branch/main/<path>km-wiki-ingestbaseGitea 站根)
// kb://<path>=用戶自己同步資料夾內的相對路徑,雲端沒有對應網頁 → 回空字串不造死鏈;
// 顯示交給 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 '<div class="nbrow" data-aisrc="' + esc(page) + '">' +
(mode ? '<span class="tag" style="flex:none">' + esc(mode) + '</span>' : '') +
'<span style="word-break:break-word">' + esc(page || '(無頁名)') + '</span>' +
(href ? '<a href="' + esc(href) + '" target="_blank" rel="noopener" style="flex:none;color:var(--amber);font-size:13px" title="開啟來源檔">來源 ↗</a>' : '') +
(!href && localPath ? '<span class="dim mono" style="flex:none;font-size:12.5px" title="' + esc(localPath) + '">在你的同步資料夾內</span>' : '') +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none"></span></div>';
}).join('') + '</div>';
}
@@ -848,9 +872,17 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
(src
? (function () {
var href = srcHref(src);
var body = href
? '<a class="mono" style="font-size:14px;color:var(--amber);word-break:break-all" href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(src) + ' ↗</a>'
: '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(src) + '</span>';
var localPath = srcLocalPath(src);
var body;
if (href) {
body = '<a class="mono" style="font-size:14px;color:var(--amber);word-break:break-all" href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(src) + ' ↗</a>';
} else if (localPath) {
// kb://=本地同步資料夾的相對路徑,雲端沒有對應網頁——誠實顯示路徑文字+標註,不造死鏈。
body = '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(localPath) + '</span>' +
'<span class="dim" style="font-size:12.5px;flex:none">在你的同步資料夾內</span>';
} else {
body = '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(src) + '</span>';
}
return '<div class="srcline"><span style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none">來源回溯</span>' + body + '</div>';
})()
: '');
@@ -0,0 +1,108 @@
/**
* Portal 溯源 scheme 測試daemon-beta t21 kb:// vs gitea:// 斷鏈地雷)
*
* 驗的是 src/portal-ui.ts 內嵌客戶端 JS srcHref / srcLocalPath
* gitea://<path> → {base}/<path>(舊 rag_ingest v2 實形,行為不回歸)
* gitea:<org/repo>@<path> {base}/<org/repo>/src/branch/main/<path>km-wiki-ingest
* registry/examples/km-wiki-ingest/workflow.yaml `gitea:${repo}@${relPath}`
* kb://<path> → 不造連結(雲端無對應網頁),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 <name>(...) { ... }` 全文(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://<path>(舊 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:<org/repo>@<path>km-wiki-ingest)— base/<org/repo>/src/branch/main/<path>', () => {
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://<path>(新 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 降級');
});