diff --git a/design-preview/index.html b/design-preview/index.html index ace6bd4..0833323 100644 --- a/design-preview/index.html +++ b/design-preview/index.html @@ -354,16 +354,19 @@
-
华夏基金智能助手
在线为您提供基金信息服务
+
华夏基金智能助手
在线为您提供基金信息服务
- +
-
您好,我是华夏基金智能助手。您可以咨询基金产品、风险等级和净值信息。
-
稳健型的我能买股票型基金吗?
-
风险等级 C2(保守型)通常不建议购买 R4 股票型基金, - 两者风险等级不匹配。您可以关注 R2 债券型或 R3 混合型产品。

- 以上信息仅供参考,不构成投资建议。
+
正在建立会话...
+
@@ -1020,11 +1023,35 @@ function showScreen(id){ /* 客户/游客助手挂在全局 layout,后台不显示(后台用投顾助手悬浮球) */ document.getElementById("client-agent").style.display = NO_CLIENT_AGENT.includes(id) ? "none" : "block"; - document.getElementById("ca-win").style.display = "none"; + closeCA(); +} +/* 打开面板 = 先建会话(session/create),拿到会话号后才允许对话 */ +function openCA(){ + const w = document.getElementById("ca-win"); + if (w.style.display !== "none") return; + w.style.display = "flex"; + const state = document.getElementById("ca-state"); + document.getElementById("ca-connecting").style.display = "block"; + document.getElementById("ca-messages").style.display = "none"; + state.textContent = "正在建立会话..."; + clearTimeout(window.__caTimer); + window.__caTimer = setTimeout(()=>{ + state.textContent = "会话 #" + Math.random().toString(16).slice(2,8) + " · 已接入您的账户数据"; + document.getElementById("ca-connecting").style.display = "none"; + document.getElementById("ca-messages").style.display = "block"; + }, 700); +} +/* 关闭面板 = 结束会话(session/end),丢弃服务端上下文 */ +function closeCA(){ + const w = document.getElementById("ca-win"); + if (w.style.display === "none") return; + w.style.display = "none"; + clearTimeout(window.__caTimer); + document.getElementById("ca-state").textContent = "会话已结束"; } function toggleCA(){ const w = document.getElementById("ca-win"); - w.style.display = w.style.display==="none" ? "flex" : "none"; + w.style.display === "none" ? openCA() : closeCA(); } function go(id){ const sec = ADMIN_SECTIONS.find(s=>s.id===id); diff --git a/frontend/components/admin/assistant-launcher.tsx b/frontend/components/admin/assistant-launcher.tsx index da94bad..fb1d4bb 100644 --- a/frontend/components/admin/assistant-launcher.tsx +++ b/frontend/components/admin/assistant-launcher.tsx @@ -58,7 +58,8 @@ export function AssistantLauncher() { const [open, setOpen] = useState(false); const [tab, setTab] = useState<"chat" | "drafts">("chat"); - const [sessionId, setSessionId] = useState(() => newSessionId()); + /* 会话在「打开抽屉」时才建立(见 openAssistant),关闭时丢弃,不在挂载时就占用上下文 */ + const [sessionId, setSessionId] = useState(null); const [customers, setCustomers] = useState([]); const [customerId, setCustomerId] = useState(null); const [messages, setMessages] = useState([]); @@ -87,7 +88,7 @@ export function AssistantLauncher() { return () => clearInterval(timer); }, [streaming]); - const sessionTag = useMemo(() => sessionId.slice(-6), [sessionId]); + const sessionTag = useMemo(() => (sessionId ? sessionId.slice(-6) : "--------"), [sessionId]); /* 客户列表只用于会话上下文选择,失败不阻断助手 */ useEffect(() => { @@ -101,8 +102,18 @@ export function AssistantLauncher() { })(); }, []); - /* 会话 ID 变化时恢复该会话的历史;新会话为空属正常,不报错。 */ + /* + * 会话握手:打开抽屉(sessionId 就绪)后拉一次该会话的记录。 + * 投顾 Agent 没有独立的 session/create,后端按 (advisor_id, session_id) 惰性建立上下文, + * 所以这个请求就是「打开助手时发出的会话请求」;关闭抽屉会把 sessionId 置空、丢弃上下文。 + */ useEffect(() => { + if (!sessionId) { + setMessages([]); + setHistoryError(null); + setHistoryLoading(false); + return; + } let alive = true; void (async () => { setHistoryLoading(true); @@ -182,7 +193,7 @@ export function AssistantLauncher() { const result = await assistantApi.chatOnce( { query, - session_id: sessionId, + session_id: sessionId ?? undefined, scope: customerId ? "customer" : "advisor", customer_id: customerId, }, @@ -241,7 +252,7 @@ export function AssistantLauncher() { setStreaming(true); try { if (tool === "data-query") { - const result = await assistantApi.dataQuery({ query, session_id: sessionId }); + const result = await assistantApi.dataQuery({ query, session_id: sessionId ?? undefined }); pushPair(query, result.answer ?? result.summary ?? "查询完成,无文本摘要。", "data_query"); } else if (tool === "fund-analysis") { const result = await assistantApi.fundAnalysis(query); @@ -272,7 +283,30 @@ export function AssistantLauncher() { [customerId, input, loadDrafts, pushPair, sessionId, toast] ); - /** 结束会话:中止生成、清空上下文并启用新的 session_id。 */ + /** + * 打开抽屉 = 建立会话。 + * 投顾 Agent 后端只有 chat/stream 与 session/{id}/history,没有独立的 session/create, + * 所以这里生成会话键,由上面的 useEffect 调 history 完成握手(后端按需建立上下文)。 + */ + const openAssistant = useCallback(() => { + setOpen(true); + setSessionId((current) => current ?? newSessionId()); + }, []); + + /** + * 关闭抽屉 = 结束会话:中止生成、清空消息并丢弃上下文。 + * 投顾侧后端未提供 session/end,因此结束只能在前端完成(下次打开是新的 session_id)。 + */ + const closeAssistant = useCallback(() => { + abortRef.current?.abort(); + setOpen(false); + setMessages([]); + setInput(""); + setStreaming(false); + setSessionId(null); + }, []); + + /** 抽屉内「结束会话」:不等关闭,立即换一个全新会话并重新握手。 */ const endSession = useCallback(() => { abortRef.current?.abort(); setMessages([]); @@ -328,7 +362,7 @@ export function AssistantLauncher() { <> {!open && ( +
-
- {messages.map((message) =>
{message.content}
)} - {loading &&
正在整理信息...
} + +
+ {messages.map((message) => ( +
+
+ {message.content} +
+
+ ))} + + {connecting && ( +
+ + 正在建立会话... +
+ )} + {sending && !connecting &&
正在整理信息...
}
-
{ event.preventDefault(); void sendMessage(); }} className="flex gap-2 border-t border-slate-200 bg-white p-3"> - setInput(event.target.value)} placeholder="输入您的问题" className="min-w-0 flex-1 rounded-xl border border-slate-200 px-3 py-2.5 text-sm outline-none transition focus:border-[var(--brand-primary)]" /> - + + {error && ( +
+ {error} + {!ready && ( + + )} +
+ )} + + { + event.preventDefault(); + void sendMessage(); + }} + className="flex gap-2 border-t border-slate-200 bg-white p-3" + > + setInput(event.target.value)} + placeholder={ready ? "输入您的问题" : "正在准备会话..."} + disabled={connecting} + className="min-w-0 flex-1 rounded-xl border border-slate-200 px-3 py-2.5 text-sm outline-none transition focus:border-[var(--brand-primary)] disabled:bg-slate-50" + /> +
)} - + + ); } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index d0255dd..8ba127c 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -29,6 +29,22 @@ function handleUnauthorized(status: number) { if (!window.location.pathname.startsWith("/login")) window.location.href = "/login"; } +/** + * 从错误响应体里取后端给的原因(如「会话不存在或已过期」)。 + * 非 2xx 时后端返回的是 JSON(utils/response.fail),部分场景也会包成 SSE。 + */ +function extractErrorMessage(body: string): string | undefined { + const text = body.trim(); + if (!text) return undefined; + const jsonText = text.startsWith("data:") ? text.split("\n")[0].slice(5).trim() : text; + try { + const payload = JSON.parse(jsonText) as { message?: string; error?: string }; + return payload?.message ?? payload?.error; + } catch { + return undefined; + } +} + /** * 后端存在两套成功码,必须都认: * - utils/response.success → code 200(工作台、风控、工单等) @@ -126,6 +142,8 @@ export interface SSESummary { traceId?: string; /** error 事件里的提示文案。 */ error?: string; + /** 客服 Agent 命中的知识来源(裸 JSON 响应里的 sources)。 */ + sources?: unknown[]; } /** @@ -152,7 +170,13 @@ export async function readSSE( }); handleUnauthorized(response.status); - if (!response.ok) throw new ApiError("请求失败,请稍后重试", response.status); + if (!response.ok) { + const body = await response.text(); + throw new ApiError( + extractErrorMessage(body) ?? "请求失败,请稍后重试", + response.status + ); + } const summary: SSESummary = { text: "" }; const raw = await response.text(); @@ -170,29 +194,45 @@ export async function readSSE( continue; /* 忽略心跳与非 JSON 块 */ } - if (event.type === "text") { - summary.text += event.content ?? ""; - } else if (event.type === "meta") { - summary.intent = event.intent; - summary.draftId = event.draft_id; - summary.queryId = event.query_id; - summary.traceId = event.trace_id; - } else if (event.type === "done") { - if (event.draft_id) summary.draftId = event.draft_id; - if (event.query_id) summary.queryId = event.query_id; - } else if (event.type === "error") { - summary.error = event.message ?? "助手返回错误,请稍后重试"; + const record = event as Record; + const type = typeof record.type === "string" ? record.type : undefined; + + if (type === "text") { + summary.text += (record.content as string) ?? ""; + } else if (type === "meta") { + summary.intent = record.intent as string | undefined; + summary.draftId = record.draft_id as string | undefined; + summary.queryId = record.query_id as string | undefined; + summary.traceId = record.trace_id as string | undefined; + } else if (type === "done") { + if (record.draft_id) summary.draftId = record.draft_id as string; + if (record.query_id) summary.queryId = record.query_id as string; + } else if (type === "error") { + summary.error = (record.message as string) ?? "助手返回错误,请稍后重试"; + } else if (!type) { + /* + * 无 type 的响应——客服 Agent / 客户 Agent 不用事件包装,直接 dump 业务对象: + * service/customer_agent/chat.py → {answer, sources, intent, rewritten_query, trace_id} + * 另兼容 success() 包装的 {code, message, data}(取值时自动下钻一层 data)。 + * 旧实现只认 type=text,导致这两个 Agent 的回复恒为空串(聊天框永远回「暂时没有找到合适的回答」)。 + */ + const node = + record.data && typeof record.data === "object" + ? (record.data as Record) + : record; + const answer = node.answer ?? node.content ?? node.summary; + if (typeof answer === "string" && answer) { + summary.text += answer; + } else if (typeof record.message === "string" && record.message) { + summary.error = record.message; + } + if (typeof node.intent === "string") summary.intent = node.intent; + if (typeof node.query_id === "string") summary.queryId = node.query_id; + const trace = node.trace_id ?? record.trace_id; + if (typeof trace === "string") summary.traceId = trace; + if (Array.isArray(node.sources)) summary.sources = node.sources; } } return summary; } - -/** - * 取 SSE 响应的正文文本(客服/客户 Agent 聊天用)。 - * 旧实现只取第一个 data 行,命中 meta 事件时会拿到空串,这里改为聚合全部 text 事件。 - */ -export async function apiStream(path: string, body: unknown): Promise { - const summary = await readSSE(path, body); - return summary.error || summary.text; -} diff --git a/frontend/lib/chat-agent-api.ts b/frontend/lib/chat-agent-api.ts new file mode 100644 index 0000000..110cf59 --- /dev/null +++ b/frontend/lib/chat-agent-api.ts @@ -0,0 +1,44 @@ +import { apiFetch, readSSE, type SSESummary } from "@/lib/api"; + +/** + * 客户端两个 Agent 的会话适配层。 + * + * 两者都是「打开建会话 → 带 session_id 对话 → 关闭结束会话」的三段式, + * 后端的会话归属校验会拒绝未创建或不属于自己的 session_id(SessionOwnershipError), + * 所以 session_id 必须来自 session/create,不能在前端自己造。 + * + * - customer:匿名客服 Agent(/api/agent/customer/*),游客与登录用户都可用,无需 token + * - client :登录客户 Agent(/api/agent/client/*),需 require_customer, + * 能读到本人持仓 / 交易(匿名版没有 customer_id,数据查询会被引导去登录) + */ +export type ChatAgentMode = "customer" | "client"; + +const PREFIX: Record = { + customer: "/agent/customer", + client: "/agent/client", +}; + +export interface AgentSession { + session_id: string; + customer_id: number | null; +} + +export const chatAgentApi = { + /** POST /session/create —— 建立会话,返回后端签发的 session_id。 */ + createSession: (mode: ChatAgentMode) => + apiFetch(`${PREFIX[mode]}/session/create`, { method: "POST" }), + + /** POST /chat —— 响应是 SSE 包装,但内容为 {answer, sources, intent...}。 */ + chat: ( + mode: ChatAgentMode, + body: { session_id: string; query: string }, + signal?: AbortSignal + ): Promise => readSSE(`${PREFIX[mode]}/chat`, body, signal), + + /** POST /session/end —— 关闭会话并清理服务端上下文与限流键。 */ + endSession: (mode: ChatAgentMode, sessionId: string) => + apiFetch<{ session_id: string; archived?: boolean }>(`${PREFIX[mode]}/session/end`, { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }), +}; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index a419cbe..ce4e94a 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.