diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 54fdb24..6dd5ad3 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -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)。 diff --git a/cypher-executor/src/routes/portal-ui.ts b/cypher-executor/src/routes/portal-ui.ts index 654f1d5..26a7ae6 100644 --- a/cypher-executor/src/routes/portal-ui.ts +++ b/cypher-executor/src/routes/portal-ui.ts @@ -236,6 +236,17 @@ function renderPortalHtml(brand: string): string { + +
+
AI 問答・答案附出處,可回搜尋驗證
+
+ + +
+
+
@@ -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 = '
AI 查閱知識庫中…
'; + 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 = '
問不到
' + esc(x.d.error || ('問答失敗(HTTP ' + x.status + ')')) + '
'; + return; + } + var srcs = x.d.sources || []; + // answer 純文字渲染、保留換行(pre-wrap+esc,不走 markdown、防 XSS) + var html = '
' + + '
' + esc(x.d.answer || '(AI 沒有給出答案)') + '
'; + if (srcs.length) { + html += '
出處(點頁名帶入搜尋框)
' + + '
' + srcs.map(function (s) { + var page = (s && (s.page || s.page_name)) || ''; + var mode = (s && s.mode) || ''; + return '
' + + (mode ? '' + esc(mode) + '' : '') + + '' + esc(page || '(無頁名)') + '' + + '
'; + }).join('') + '
'; + } + html += '
'; + $('ai-out').innerHTML = html; + }) + .catch(function (e) { $('ai-go').disabled = false; $('ai-out').innerHTML = '
請求失敗:' + esc(friendlyErr(e)) + '
'; }); + } + $('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';