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:
@@ -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",
|
||||
|
||||
@@ -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://<path> → {base}/<path>(base 已含 repo 瀏覽前綴;舊 rag_ingest v2,行為不變)
|
||||
// gitea:<org/repo>@<path> → {base}/<org/repo>/src/branch/main/<path>(km-wiki-ingest;base=Gitea 站根)
|
||||
// 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>';
|
||||
})()
|
||||
: '');
|
||||
|
||||
@@ -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://<path> → {base}/<path>(base 已含 repo 瀏覽前綴;舊 rag_ingest v2,行為不變)
|
||||
// gitea:<org/repo>@<path> → {base}/<org/repo>/src/branch/main/<path>(km-wiki-ingest;base=Gitea 站根)
|
||||
// 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 降級');
|
||||
});
|
||||
Reference in New Issue
Block a user