Merge pull request 'portal demo 化四件套:圖譜 workflow 化(#57)+AI 問答+上傳頁+搜尋濾殘影(#46 治標)' (#61) from portal-demo-suite into main

This commit was merged in pull request #61.
This commit is contained in:
Leo
2026-07-17 06:48:04 +00:00
5 changed files with 443 additions and 7 deletions
+218 -3
View File
@@ -23,11 +23,56 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible } from './portal';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
import { graphBase } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
// ── tenant workflow in-process 執行(portal-demo-suite)───────────────────────
//
// 為什麼:RAG self-hosted 實例不部署 kbdb-graph-plugin workergraphBase 直連會 1042
// Arcrun#57);graph/chat 這類查詢能力在該實例是以 tenant 的 named workflow 形式存在
//WEBHOOKS KV `{tenant}:wf:{name}`,與 /webhooks/named 同一資料源)。
// 怎麼接:直接 import executeWebhookGraphwebhooks-named.ts trigger/query 端點同一個
// 執行入口)在 **本 worker 進程內** 執行——絕不 fetch 自己的 hostname
//global_fetch_strictly_public 下打自己=self-loop,見 /portal/data/workflows 同款註解)。
/** 讀 tenant 的 named workflow graph`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text');
if (!raw) return null;
try {
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
return rec.graph && typeof rec.graph === 'object' ? rec.graph : null;
} catch {
return null; // 壞 record 視同不存在(呼叫端各自誠實回報)
}
}
/** workflow 最終節點輸出常見兩形:本體即結果,或再包一層 data(http_request 類零件慣例)。取有目標欄位的那層。 */
function unwrapWorkflowData(data: unknown, key: string): Record<string, unknown> {
const outer = data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
if (key in outer) return outer;
const inner = outer.data;
if (inner && typeof inner === 'object' && key in (inner as Record<string, unknown>)) {
return inner as Record<string, unknown>;
}
return outer;
}
/**
* graph_neighbors workflow 輸出 → 與 kbdb-graph-plugin 相同的回應形狀 {neighbors, edges, count}
*(前端 doGraphSearch 讀 neighbors/edgescountneighbors 數,重算不信 workflow 自報)。
* 純函式(單測用 export)。
*/
export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; edges: unknown[]; count: number } {
const layer = unwrapWorkflowData(data, 'neighbors');
const neighbors = Array.isArray(layer.neighbors) ? layer.neighbors : [];
const edges = Array.isArray(layer.edges) ? layer.edges : [];
return { neighbors, edges, count: neighbors.length };
}
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
function notFound(c: Context<{ Bindings: Bindings }>): Response {
return c.json({ error: '找不到這筆資料' }, 404);
@@ -49,6 +94,28 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
return userLibraries.includes('*') || userLibraries.includes(library);
}
/**
* 搜尋殘影過濾(**Arcrun#46 上游修好前的 portal 端治標**——舊管線的 deprecated 產物還躺在
* KBDB 裡污染搜尋結果;根治=上游清資料/重建索引,那修好後這段可整段拔掉)。
* 濾掉:metadata_json.status === 'deprecated' 的 entry、content 以「(舊管線產物」開頭的 entry。
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
* 純函式(單測用 export)。
*/
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null }>(
entries: T[],
): T[] {
return entries.filter((e) => {
try {
const meta = JSON.parse(e.metadata_json ?? 'null') as { status?: unknown } | null;
if (meta && meta.status === 'deprecated') return false;
} catch {
/* parse 失敗 → 保留 */
}
if (String(e.content ?? '').startsWith('(舊管線產物')) return false;
return true;
});
}
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
//graph 走 /portal/data/graph/*)。server 注入 owner_idlibrary;回應照 KBDB 原形
//entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
@@ -77,7 +144,21 @@ portalDataRouter.get('/portal/data/search', (c) =>
if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit);
const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`);
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
if (!res.ok) {
// 錯誤回應照原樣透傳(誠實,不加工)
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
// Arcrun#46 治標:server-side 濾掉舊管線 deprecated 殘影再回(count 重算)。
// 上游修好(清資料/重建索引)後,這段連同 filterDeprecatedEntries 一起拔掉。
const body = (await res.json().catch(() => null)) as
| { entries?: { metadata_json?: string | null; content?: string | null }[]; count?: number }
| null;
if (!body || !Array.isArray(body.entries)) {
// 回應不是預期形狀 → 照原樣回(不因治標把正常錯誤形狀吃掉)
return c.json(body ?? { error: 'KBDB 回應不是 JSON' }, body ? 200 : 502);
}
const entries = filterDeprecatedEntries(body.entries);
return c.json({ ...body, entries, count: entries.length });
}),
);
@@ -106,7 +187,10 @@ portalDataRouter.get('/portal/data/entries/:id', (c) =>
// GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘):
// 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。
// 放行後純轉發 kbdb-graph-plugintoken 只在 server 側,同 kbdb-proxy 慣例)。
// 放行後的資料源二選一(Arcrun#57):
// ① tenant 有 `{tenant}:wf:graph_neighbors` workflow → in-process 執行它(RAG self-hosted
// 實例不部署 kbdb-graph-plugin,直連會 1042),輸出映射成與 plugin 相同形狀;
// ② 沒有 → fallback 原本 graphBase 直連 pluginMira/leo21c 實例相容,行為一字不變)。
portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
@@ -115,6 +199,29 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
if (!(await hasGraphAccess(c.env, libraries))) {
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = portalTenant(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,
{ node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
'graph_neighbors',
tenant,
c.executionCtx,
);
if (!result.success) {
// workflow 執行失敗 → 誠實 502(不假裝無關聯)
return c.json({ error: `graph_neighbors workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
}
return c.json(mapGraphWorkflowOutput(result.data));
}
// ② plugin fallbackMira/leo21c 相容)
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
@@ -128,6 +235,114 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
}),
);
// GET /portal/data/chat?question=... — AI 問答(portal-demo-suite)。
// 設計哲學:AI 檢索=用戶手動搜尋同一套——同 search 的 requirePortalUser 閘、同一個租戶資料面,
// 只是把「人下關鍵字」換成「workflow 代查再作答」;前端不因走 AI 多拿任何權限。
// 機制同 graph_neighborsin-process 執行 tenant 的 rag_chat workflowexecuteWebhookGraph
// 絕不 fetch 自己 hostname);workflow 不存在 → 誠實 404,不假裝這實例有問答能力。
portalDataRouter.get('/portal/data/chat', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const question = c.req.query('question');
if (!question) return c.json({ error: 'question 必填' }, 400);
const wfGraph = await getTenantWorkflowGraph(c.env, 'rag_chat');
if (!wfGraph) return c.json({ error: '此實例未安裝問答 workflow' }, 404);
const result = await executeWebhookGraph(
c.env,
wfGraph,
{ question },
'rag_chat',
portalTenant(c.env),
c.executionCtx,
);
if (!result.success) {
// workflow 執行失敗 → 誠實 502(不把錯誤編成答案)
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
}
// 回 workflow 回應內層 data{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
const inner = unwrapWorkflowData(result.data, 'answer');
return c.json({
answer: typeof inner.answer === 'string' ? inner.answer : '',
sources: Array.isArray(inner.sources) ? inner.sources : [],
graph_facts: inner.graph_facts ?? null,
});
}),
);
// ── 上傳(portal-demo-suite)────────────────────────────────────────────────
/**
* 上傳檔名驗證(純函式,單測用 export):
* - 去路徑分隔(/ \)只取最後一段(擋 ../ 穿越)、去控制字元;
* - 空名/以 . 開頭(隱藏檔/./..)→ 無效(null);
* - 強制 .md 結尾(.txt 改副檔名、其餘直接補 .md——上傳面收的是知識文件,一律當 markdown 收件);
* - 最終長度限 100(含副檔名),超過 → 無效。
*/
export function sanitizeUploadFilename(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
let name = (raw.split(/[/\\]/).pop() ?? '').trim();
// eslint-disable-next-line no-control-regex
name = name.replace(/[\u0000-\u001f\u007f]/g, '');
if (!name || name.startsWith('.')) return null;
if (/\.txt$/i.test(name)) name = name.replace(/\.txt$/i, '.md');
if (!/\.md$/i.test(name)) name = `${name}.md`;
if (name.length > 100) return null;
return name;
}
// base64 內容上限(收 .md/.txt 知識文件,2 MiB 原文 ≈ 2.7 MB base64 已綽綽有餘;防拿上傳面塞大檔)
const MAX_UPLOAD_B64_CHARS = 3 * 1024 * 1024;
// POST /portal/data/upload — body {filename, content_b64}portal-demo-suite)。
// requirePortalUser 閘;server-side POST Gitea contents API 寫進 PORTAL_UPLOAD_REPO 的
// docs/{filename}——token 只在 server 側,前端永遠拿不到(同 kbdb-proxy token 慣例)。
// 未設 upload bindings(三者任一缺)→ 404「未啟用上傳」(Mira 零影響:功能不存在)。
portalDataRouter.post('/portal/data/upload', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
if (!uploadEnabled(c.env)) return c.json({ error: '此實例未啟用上傳' }, 404);
const body = await c.req.json().catch(() => null) as { filename?: unknown; content_b64?: unknown } | null;
const filename = sanitizeUploadFilename(body?.filename);
if (!filename) return c.json({ error: 'filename 無效(不可含路徑、不可空、長度限 100)' }, 400);
const contentB64 = body?.content_b64;
if (typeof contentB64 !== 'string' || !contentB64) return c.json({ error: 'content_b64 必填' }, 400);
if (contentB64.length > MAX_UPLOAD_B64_CHARS) return c.json({ error: '檔案過大(上限約 2 MB' }, 413);
const base = (c.env.PORTAL_UPLOAD_GITEA ?? '').replace(/\/$/, '');
const repo = c.env.PORTAL_UPLOAD_REPO ?? '';
let res: Response;
try {
res = await fetch(`${base}/api/v1/repos/${repo}/contents/docs/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `token ${c.env.PORTAL_UPLOAD_TOKEN}`,
},
body: JSON.stringify({
content: contentB64,
// commit 訊息帶上傳者(display_name 非機密),收件溯源用;不進任何內容
message: `portal 上傳:docs/${filename}${auth.user.values.display_name ?? 'portal user'}`,
}),
});
} catch (e) {
return c.json({ error: `知識庫收件服務不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
}
if (res.status === 409 || res.status === 422) {
// Gitea 同 path 已存在(版本差異回 409 或 422)→ 統一誠實回 409
return c.json({ error: '同名文件已存在,請改檔名後重傳' }, 409);
}
if (!res.ok) {
return c.json({ error: `知識庫收件失敗(HTTP ${res.status}` }, 502);
}
return c.json({ success: true, filename, path: `docs/${filename}` });
}),
);
// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger**
//trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。
// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403/ all / off(整頁不存在 → 404)。
+151 -3
View File
@@ -215,6 +215,7 @@ function renderPortalHtml(brand: string): string {
<div id="sidenav">
<div class="logo">${brand}</div>
<div class="nav" data-nav="search">搜尋</div>
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
<div class="nav hide" id="nav-admin" data-nav="admin">管理</div>
<div class="nav" data-nav="settings">設定</div>
@@ -236,6 +237,17 @@ function renderPortalHtml(brand: string): string {
<button class="modebtn" data-mode="semantic">語意</button>
<button class="modebtn hide" id="mode-graph" data-mode="graph">圖譜</button>
</div>
<!-- AI 問答(portal-demo-suite)。設計哲學:AI 檢索=用戶手動搜尋同一套——
/portal/data/chat 與 /portal/data/search 走同一個 session 閘、同一個租戶資料面,
AI 只是代查再作答,不多開任何權限。此實例未安裝問答 workflow 時 server 誠實 404。-->
<div style="margin-top:14px;padding:14px;border-radius:13px;background:rgba(var(--amber-rgb),.05);border:1px solid rgba(var(--amber-rgb),.25)">
<div style="font-size:13.5px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.5);margin-bottom:9px">AI 問答・答案附出處,可回搜尋驗證</div>
<div class="searchrow">
<input id="ai-q" placeholder="用一句話問知識庫…" autocomplete="off" style="padding:12px 15px;font-size:16px">
<button class="btn" id="ai-go" style="flex:none;padding:0 20px">問 AI</button>
</div>
<div id="ai-out"></div>
</div>
<div id="se-banner"></div>
<div id="se-count" style="margin:20px 0 12px;font-size:14px;color:rgba(var(--ink-rgb),.5)"></div>
<div class="kgrid" id="se-results"></div>
@@ -255,6 +267,22 @@ function renderPortalHtml(brand: string): string {
<div id="cd-main" style="padding:24px 0 40px"><div class="muted">載入中…</div></div>
</div>
<!-- 上傳(portal-demo-suiteupload bindings 齊全才顯示 nav——/portal/session 的
upload_enabled;藏只是 UX,真閘在 /portal/data/upload 路由層 404-->
<div class="view page narrow" id="v-upload">
<div class="pagehead"><span class="t">上傳</span><span class="m">Markdown / 純文字</span></div>
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
上傳的文件會進入知識庫收件管線:<b style="color:var(--ink)">約 1 分鐘後可在搜尋頁找到</b>;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。
</div>
<div id="up-drop" style="margin-top:16px;padding:44px 20px;border-radius:13px;border:2px dashed rgba(var(--amber-rgb),.4);background:rgba(var(--amber-rgb),.04);text-align:center;cursor:pointer">
<div style="font-size:16px;color:rgba(var(--ink-rgb),.7)">把 .md / .txt 檔案拖放到這裡</div>
<div class="muted" style="margin-top:8px;font-size:14px">或</div>
<button class="btn2" id="up-pick" style="margin-top:10px;padding:11px 22px;font-size:15px">選擇檔案(可多選)</button>
<input type="file" id="up-file" accept=".md,.txt" multiple style="display:none">
</div>
<div class="listcol" id="up-list"></div>
</div>
<!-- 工作流(D-8:預設 admin 才看得到;唯讀,不開 trigger-->
<div class="view page narrow" id="v-workflows">
<div class="pagehead"><span class="t">工作流</span><span class="m">唯讀・系統狀態</span></div>
@@ -338,6 +366,7 @@ function renderPortalHtml(brand: string): string {
<!-- 手機底部導覽 -->
<div id="tabbar">
<div class="tab" data-nav="search">搜尋</div>
<div class="tab hide" id="tab-upload" data-nav="upload">上傳</div>
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
<div class="tab hide" id="tab-admin" data-nav="admin">管理</div>
<div class="tab" data-nav="settings">設定</div>
@@ -468,14 +497,15 @@ function renderPortalHtml(brand: string): string {
}
// ── 路由 ──
var VIEWS = ['search', 'card', 'workflows', 'admin', 'settings'];
var VIEWS = ['search', 'card', 'upload', 'workflows', 'admin', 'settings'];
var HOME = 'search';
function allowedViews() {
// 工作流/管理頁按 session 能力顯示(真閘在 server/portal/data/workflows 403/404、
// /portal/admin/* role 閘 403——前端藏只是 UX,curl 直打也繞不過)
// 工作流/管理/上傳頁按 session 能力顯示(真閘在 server/portal/data/workflows 403/404、
// /portal/admin/* role 閘 403、/portal/data/upload 未啟用 404——前端藏只是 UX,curl 直打也繞不過)
return VIEWS.filter(function (v) {
if (v === 'workflows') return !!(S.profile && S.profile.workflows_visible);
if (v === 'admin') return !!(S.profile && S.profile.role === 'admin');
if (v === 'upload') return !!(S.profile && S.profile.upload_enabled);
return true;
});
}
@@ -524,6 +554,9 @@ function renderPortalHtml(brand: string): string {
$('side-foot').innerHTML = esc(p.display_name || '') + '<br>' + esc(location.host);
$('nav-workflows').classList.toggle('hide', !p.workflows_visible);
$('tab-workflows').classList.toggle('hide', !p.workflows_visible);
// 上傳 nav:一般用戶可見,但只在實例啟用上傳(bindings 齊全)時顯示(未啟用的實例零影響)
$('nav-upload').classList.toggle('hide', !p.upload_enabled);
$('tab-upload').classList.toggle('hide', !p.upload_enabled);
$('nav-admin').classList.toggle('hide', p.role !== 'admin');
$('tab-admin').classList.toggle('hide', p.role !== 'admin');
$('mode-graph').classList.toggle('hide', !p.graph_allowed);
@@ -642,6 +675,56 @@ function renderPortalHtml(brand: string): string {
if (t) nav('card', t.getAttribute('data-card'));
});
// ── AI 問答(portal-demo-suite)──
// 設計哲學:AI 檢索=用戶手動搜尋同一套——同 session token、同 /portal/data/* enforce 面,
// AI 只代查再作答;答案附 sourcesmodepage),點 page 帶回搜尋框讓用戶自己驗證。
$('ai-go').addEventListener('click', doAsk);
$('ai-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doAsk(); });
function doAsk() {
var q = $('ai-q').value.trim();
if (!q) { toast('請先輸入問題'); return; }
$('ai-go').disabled = true;
$('ai-out').innerHTML = '<div class="muted" style="margin-top:12px">AI 查閱知識庫中…</div>';
fetch('/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('ai-go').disabled = false;
if (guard401(x.status)) return;
if (!x.ok) {
// 404=此實例未安裝問答 workflow(server 誠實回報,前端不假裝有 AI)
$('ai-out').innerHTML = '<div class="honest" style="margin-top:12px"><div class="h">問不到</div><div class="b">' + esc(x.d.error || ('問答失敗(HTTP ' + x.status + '')) + '</div></div>';
return;
}
var srcs = x.d.sources || [];
// answer 純文字渲染、保留換行(pre-wrapesc,不走 markdown、防 XSS
var html = '<div class="panel" style="margin-top:12px">' +
'<div style="white-space:pre-wrap;font-size:16px;line-height:1.85;word-break:break-word">' + esc(x.d.answer || 'AI 沒有給出答案)') + '</div>';
if (srcs.length) {
html += '<div style="margin-top:14px;font-size:13px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.5)">出處(點頁名帶入搜尋框)</div>' +
'<div style="margin-top:7px;display:flex;flex-direction:column;gap:6px">' + srcs.map(function (s) {
var page = (s && (s.page || s.page_name)) || '';
var mode = (s && s.mode) || '';
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>' +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none"></span></div>';
}).join('') + '</div>';
}
html += '</div>';
$('ai-out').innerHTML = html;
})
.catch(function (e) { $('ai-go').disabled = false; $('ai-out').innerHTML = '<div class="err" style="margin-top:12px">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
$('ai-out').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-aisrc]');
if (!t) return;
var page = t.getAttribute('data-aisrc');
if (!page) return;
$('se-q').value = page;
$('se-q').focus();
window.scrollTo(0, 0);
});
// ── graph 模式(D-4 粗閘:無權者按鈕根本不顯示;就算 curl 直打 API 也是 403)──
function doGraphSearch(name) {
$('se-results').style.display = 'none';
@@ -749,6 +832,71 @@ function renderPortalHtml(brand: string): string {
.catch(function (e) { $('wf-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
// ── 上傳頁(portal-demo-suite)──
// 前端只讀檔轉 base64 逐檔 POST /portal/data/uploadGitea 位置與 token 全在 server 側,
// 這裡看不到任何 repo/token 字串。逐檔獨立狀態列(一檔失敗不拖累其他檔)。
var UP_OK_MSG = '已收件,約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。';
function b64FromDataUrl(dataUrl) {
var i = String(dataUrl).indexOf(',');
return i >= 0 ? String(dataUrl).slice(i + 1) : '';
}
function uploadStatusRow(name) {
var row = document.createElement('div');
row.className = 'card-item';
row.innerHTML = '<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="mono" style="font-size:14.5px;word-break:break-all">' + esc(name) + '</span>' +
'<span class="up-st muted" style="margin-left:auto;font-size:14px;flex:none">讀取中…</span></div>' +
'<div class="up-msg" style="margin-top:6px;font-size:13.5px;line-height:1.65;color:rgba(var(--ink-rgb),.6)"></div>';
$('up-list').prepend(row);
return row;
}
function setRowStatus(row, ok, statusText, msg) {
var st = row.querySelector('.up-st');
st.textContent = statusText;
st.className = 'up-st ' + (ok === true ? 'ok' : (ok === false ? 'err' : 'muted'));
row.querySelector('.up-msg').textContent = msg || '';
}
function uploadOneFile(file) {
var row = uploadStatusRow(file.name);
if (!/\\.(md|txt)$/i.test(file.name)) {
setRowStatus(row, false, '已略過', '只收 .md / .txt 檔。');
return;
}
var reader = new FileReader();
reader.onerror = function () { setRowStatus(row, false, '讀檔失敗', '瀏覽器讀不到這個檔案。'); };
reader.onload = function () {
setRowStatus(row, null, '上傳中…', '');
fetch('/portal/data/upload', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; }
setRowStatus(row, true, '已收件', UP_OK_MSG + (x.d.path ? '' + x.d.path + '' : ''));
})
.catch(function (e) { setRowStatus(row, false, '失敗', friendlyErr(e)); });
};
reader.readAsDataURL(file); // dataURL 尾段即 base64unicode 安全,免手刻 btoa 編碼)
}
function handleUploadFiles(files) {
for (var i = 0; i < files.length; i++) uploadOneFile(files[i]);
}
$('up-pick').addEventListener('click', function (ev) { ev.stopPropagation(); $('up-file').click(); });
$('up-drop').addEventListener('click', function () { $('up-file').click(); });
$('up-file').addEventListener('change', function () { handleUploadFiles(this.files); this.value = ''; });
['dragover', 'dragenter'].forEach(function (t) {
$('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.12)'; });
});
['dragleave', 'drop'].forEach(function (t) {
$('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.04)'; });
});
$('up-drop').addEventListener('drop', function (ev) {
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) handleUploadFiles(ev.dataTransfer.files);
});
// ── 管理頁(P4:帳號管理+庫目錄管理。全走 /portal/admin/*,前端零業務邏輯;
// 一般用戶 nav 不顯示,且 server role 閘 403——藏只是 UX,真閘在後端)──
var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用)
+11
View File
@@ -360,6 +360,15 @@ export function workflowsVisible(env: Bindings, role: string): boolean {
return role === 'admin';
}
/**
* 上傳能力(portal-demo-suite):PORTAL_UPLOAD_REPO / PORTAL_UPLOAD_GITEA / PORTAL_UPLOAD_TOKEN
* 三個 bindings **齊全**才啟用。Mira 零影響:未設=功能不存在(/portal/data/upload 404、
* 前端 nav 隱藏)。這裡只是顯示提示——真閘在 /portal/data/upload 路由層,前端藏不藏都繞不過。
*/
export function uploadEnabled(env: Bindings): boolean {
return Boolean(env.PORTAL_UPLOAD_REPO && env.PORTAL_UPLOAD_GITEA && env.PORTAL_UPLOAD_TOKEN);
}
/**
* admin 操作目標 record 的成員資格驗證:record 的 email head entry(子 namespace 內)
* 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」,
@@ -482,6 +491,8 @@ portalRouter.get('/portal/session', (c) =>
libraries,
graph_allowed: await hasGraphAccess(c.env, libraries),
workflows_visible: workflowsVisible(c.env, role),
// portal-demo-suite:上傳頁能力(bindings 齊全才 true;同上,只是顯示提示,真閘在路由層)
upload_enabled: uploadEnabled(c.env),
});
}),
);
+9
View File
@@ -110,6 +110,15 @@ export type Bindings = {
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
PORTAL_SHOW_WORKFLOWS?: string;
// ── Portal 上傳(portal-demo-suite,三者齊全才啟用;任一缺 → /portal/data/upload 404
// 「未啟用上傳」且前端 nav 不顯示——Mira 零影響:未設=功能不存在,行為一字不變)──
// 目標 Gitea repoowner/repo,如 "Leo/arcrun-rag-demo-knowledge")。上傳落 docs/{filename}。
PORTAL_UPLOAD_REPO?: string;
// Gitea base URL(如 https://git.uncle6.me)。刻意不共用 GITEA_BASE_URL(那是駕駛艙 sprint
// 資料源、建議唯讀 token)——上傳要寫入權限,租戶/scope 都不同,混用會互相牽制。
PORTAL_UPLOAD_GITEA?: string;
// 能寫該 repo contents API 的 tokenwrangler secret)。**只在 server 側,永不下發前端**。
PORTAL_UPLOAD_TOKEN?: string;
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
+54 -1
View File
@@ -19,7 +19,7 @@
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { workflowsVisible } from '../src/routes/portal';
import { entryLibrary } from '../src/routes/portal-data';
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
@@ -353,5 +353,58 @@ describe('GET /portal/sessionP3 能力欄位)', () => {
const data = (await res.json()) as Record<string, unknown>;
expect(data.graph_allowed).toBe(true);
expect(data.workflows_visible).toBe(true);
// portal-demo-suite:測試環境未設 upload bindings → upload_enabled=falseMira 零影響預設)
expect(data.upload_enabled).toBe(false);
});
});
// ═══════════════ 7. portal-demo-suite 純函式 ═══════════════
describe('sanitizeUploadFilename(上傳檔名驗證)', () => {
it('去路徑分隔(擋穿越)、.txt 改 .md、無副檔名補 .md', () => {
expect(sanitizeUploadFilename('notes.md')).toBe('notes.md');
expect(sanitizeUploadFilename('memo.txt')).toBe('memo.md');
expect(sanitizeUploadFilename('README')).toBe('README.md');
expect(sanitizeUploadFilename('../../etc/passwd')).toBe('passwd.md'); // 只取最後一段,穿越失效
expect(sanitizeUploadFilename('a\\b\\c.md')).toBe('c.md');
expect(sanitizeUploadFilename('中文筆記.txt')).toBe('中文筆記.md');
});
it('空名/純路徑/隱藏檔/超過 100 字/非字串 → null', () => {
expect(sanitizeUploadFilename('')).toBe(null);
expect(sanitizeUploadFilename(' ')).toBe(null);
expect(sanitizeUploadFilename('docs/')).toBe(null);
expect(sanitizeUploadFilename('.env')).toBe(null);
expect(sanitizeUploadFilename('a'.repeat(120) + '.md')).toBe(null);
expect(sanitizeUploadFilename(42)).toBe(null);
expect(sanitizeUploadFilename(undefined)).toBe(null);
});
});
describe('filterDeprecatedEntriesArcrun#46 搜尋殘影治標)', () => {
it('濾 status=deprecated 與「(舊管線產物」開頭;metadata parse 失敗保留', () => {
const keepNormal = { metadata_json: '{"library":"general"}', content: '正常內容' };
const keepBadMeta = { metadata_json: 'not-json{{', content: '壞 metadata 不誤殺' };
const keepNullMeta = { metadata_json: null, content: '無 metadata' };
const dropByStatus = { metadata_json: '{"status":"deprecated"}', content: '看起來正常但已標廢' };
const dropByContent = { metadata_json: '{}', content: '(舊管線產物)殘影條目' };
const out = filterDeprecatedEntries([keepNormal, dropByStatus, keepBadMeta, dropByContent, keepNullMeta]);
expect(out).toEqual([keepNormal, keepBadMeta, keepNullMeta]);
});
it('空陣列 → 空陣列', () => {
expect(filterDeprecatedEntries([])).toEqual([]);
});
});
describe('mapGraphWorkflowOutput#57 workflow 輸出 → plugin 形狀)', () => {
it('本體有 neighbors → 直取;count 重算不信自報', () => {
const out = mapGraphWorkflowOutput({ neighbors: ['a', 'b'], edges: [{ subject: 'a', predicate: 'rel', object: 'b' }], count: 99 });
expect(out.neighbors).toEqual(['a', 'b']);
expect(out.edges.length).toBe(1);
expect(out.count).toBe(2);
});
it('包一層 datahttp_request 慣例)→ 取內層;非物件/缺欄位 → 誠實空集合', () => {
expect(mapGraphWorkflowOutput({ data: { neighbors: ['x'], edges: [] } })).toEqual({ neighbors: ['x'], edges: [], count: 1 });
expect(mapGraphWorkflowOutput(null)).toEqual({ neighbors: [], edges: [], count: 0 });
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
});
});