portal AI 問答:/portal/data/chat 端點+搜尋頁問答面板

端點:requirePortalUser 閘(同 search);in-process 執行 tenant 的 rag_chat
workflow(與 graph_neighbors 同機制),input {question},回 {answer, sources,
graph_facts};workflow 不存在 → 誠實 404「此實例未安裝問答 workflow」。
前端:搜尋框下方問答列,loading→純文字 answer(pre-wrap 保留換行、esc 防
XSS)+sources 列表(mode+page,點 page 帶回搜尋框自行驗證)。設計哲學:
AI 檢索=用戶手動搜尋同一套,AI 只代查再作答,不多開任何權限。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BH7LdhuCdVUHfHXbM7N8r5
This commit is contained in:
Claude
2026-07-17 06:20:27 +00:00
parent 204cd5bff3
commit c01c6c4d02
2 changed files with 98 additions and 0 deletions
+37
View File
@@ -199,6 +199,43 @@ 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,
});
}),
);
// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger**
//trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。
// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403/ all / off(整頁不存在 → 404)。
+61
View File
@@ -236,6 +236,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>
@@ -642,6 +653,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';