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:
@@ -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_neighbors:in-process 執行 tenant 的 rag_chat workflow(executeWebhookGraph,
|
||||
// 絕不 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)。
|
||||
|
||||
@@ -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 只代查再作答;答案附 sources(mode+page),點 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-wrap+esc,不走 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';
|
||||
|
||||
Reference in New Issue
Block a user