"use client"; import { AlertCircle, BarChart3, Bot, FileText, Info, Loader2, MessageSquareQuote, Minus, RefreshCw, Search, Send, Square, Trash2, User, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Badge, Button, EmptyState, Modal, cn, formatTime, statusTone, useToast, } from "@/components/admin/ui"; import { ApiError } from "@/lib/api"; import { errMessage } from "@/lib/admin-api"; import { advisorErrorHint, assistantApi, draftStatusLabel, hasFundCode, intentLabel, newSessionId, sceneLabel, waitForNewDraft, type AgentDraft, type DataQueryResult, type FundAnalysisItem, type FundAnalysisResult, } from "@/lib/assistant-api"; /* ------------------------------------------------------------------ 类型 */ interface DataTableSnapshot { columns: string[]; rows: Record[]; truncated: boolean; warnings: string[]; } interface ChatMessage { id: string; role: "user" | "assistant"; content: string; intent?: string; scene?: string; draftId?: string; error?: boolean; traceId?: string; /** 错误条上的「下一步怎么做」。 */ hint?: string; /** 错误条上的「换个工具重试」:工具名 + 原始问题。 */ retryTool?: ToolKey; retryQuery?: string; /** 数据查询带回来的结果集,用于气泡里的预览表和详情弹窗。 */ table?: DataTableSnapshot; /** 生成这张表的原始问题(原样送给后端,翻页重查时保持一致)。 */ tableQuery?: string; } const QUICK_TOOLS = [ { key: "data-query", label: "查客户数据", icon: Search, hint: "自然语言查询客户数据,问题里写明客户编号或姓名即可返回表格", }, { key: "fund-analysis", label: "基金分析", icon: BarChart3, hint: "问题里需带完整基金代码,形如 F000001", }, { key: "talk-script", label: "生成话术", icon: MessageSquareQuote, hint: "按场景生成合规话术(市场波动 / 投诉 / 风控拦截 / 组合偏离)", }, { key: "rebalance", label: "调仓再平衡", icon: RefreshCw, hint: "异步生成调仓草稿,仅限已签约客户", }, ] as const; type ToolKey = (typeof QUICK_TOOLS)[number]["key"]; /** 结果集分页大小:后端 /data-query 的 page_size 直接落到 SQL 的 LIMIT。 */ const TABLE_PAGE_SIZE = 20; /** 调仓草稿由后台任务生成,轮询多久算超时。 */ const REBALANCE_WAIT_MS = 30_000; /** 后端 chat/stream 未装配 rebalance 分支,命中这些词时给出「换工具」的指引。 */ const REBALANCE_WORDS = /(调仓|再平衡|组合偏离|持仓诊断)/; const SCRIPT_WORDS = /(话术|怎么和客户说|解释给客户)/; function draftKey(draft: AgentDraft): string { return draft.draft_id ?? draft.id ?? ""; } function suggestToolForError(query: string): ToolKey | undefined { if (REBALANCE_WORDS.test(query)) return "rebalance"; if (SCRIPT_WORDS.test(query)) return "talk-script"; return undefined; } function cellText(value: unknown): string { if (value === null || value === undefined || value === "") return "--"; if (typeof value === "object") return JSON.stringify(value); return String(value); } function toSnapshot(result: DataQueryResult): DataTableSnapshot | undefined { const columns = Array.isArray(result.columns) ? result.columns : []; const rows = Array.isArray(result.rows) ? result.rows : []; if (!columns.length || !rows.length) return undefined; return { columns, rows, truncated: !!result.truncated, warnings: Array.isArray(result.warnings) ? result.warnings : [], }; } /** 后端多基金命中时返回 items,单只时是扁平结构,这里统一成列表。 */ function fundItems(result: FundAnalysisResult): FundAnalysisItem[] { if (Array.isArray(result.items) && result.items.length) return result.items; return [result]; } function fundText(item: FundAnalysisItem): string { const title = [item.fund_code, item.fund_name].filter(Boolean).join(" "); const risk = item.risk_level ? ` · 风险等级 ${item.risk_level}` : ""; const head = title ? `【${title}】${risk}` : "【基金分析】"; return [head, item.analysis_text ?? ""].filter(Boolean).join("\n"); } /** * 投顾助手:右下角常驻入口。 * 悬浮球打开抽屉后可直接对话、调用 Agent 工具、管理草稿。 * * 客户不是必填项:/chat/stream 的 customer_id 可选、另外四个工具接口没有该字段, * 后端统一从问题文本里认客户(_resolve_customer_from_query),所以前端不做客户选择, * 只在用户没写明客户导致 40001 时给出可操作的提示。 * * 另外 /rebalance/run 是异步受理,草稿要靠轮询 /draft/list 才拿得到。 */ export function AssistantLauncher() { const toast = useToast(); const [open, setOpen] = useState(false); const [tab, setTab] = useState<"chat" | "drafts">("chat"); /* 会话在「打开抽屉」时才建立(见 openAssistant),关闭时丢弃,不在挂载时就占用上下文 */ const [sessionId, setSessionId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); /** streaming = SSE 对话在途;pending = 工具调用 / 草稿轮询在途。 */ const [streaming, setStreaming] = useState(false); const [pending, setPending] = useState(false); const busy = streaming || pending; const [drafts, setDrafts] = useState([]); const [draftsLoading, setDraftsLoading] = useState(false); const [rebalancePending, setRebalancePending] = useState(false); const [activeDraft, setActiveDraft] = useState(null); const [draftDraft, setDraftDraft] = useState(""); const [saving, setSaving] = useState(false); const [tableModal, setTableModal] = useState<{ title: string; query: string; page: number; pageSize: number; loading: boolean; error: string | null; columns: string[]; rows: Record[]; truncated: boolean; warnings: string[]; } | null>(null); const abortRef = useRef(null); const scrollRef = useRef(null); const draftsRef = useRef([]); const [elapsed, setElapsed] = useState(0); /* 后端是整包输出,等待期间给个计时,避免看起来像卡死 */ useEffect(() => { if (!busy) { setElapsed(0); return; } const timer = setInterval(() => setElapsed((value) => value + 1), 1000); return () => clearInterval(timer); }, [busy]); const sessionTag = useMemo(() => (sessionId ? sessionId.slice(-6) : "--------"), [sessionId]); /* * 投顾 Agent 没有独立的 session/create:后端按 (advisor_id, session_id) 惰性建立上下文, * session_id 完全由前端生成,服务端不可能存在它的历史记录, * 所以打开抽屉时不再多发一次必然为空的 /session/{id}/history(省一次鉴权往返)。 * 一旦后端补上会话持久化,再在这里补一次 hydrate 即可。 */ const loadDrafts = useCallback(async () => { setDraftsLoading(true); try { /* 不带 customer_id:后端默认返回当前投顾名下的全部草稿 */ const page = await assistantApi.drafts({ page: 1, page_size: 20 }); const items = page.items ?? []; draftsRef.current = items; setDrafts(items); } catch { /* 草稿列表加载失败不打断对话 */ } finally { setDraftsLoading(false); } }, []); useEffect(() => { if (open) void loadDrafts(); }, [open, loadDrafts]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages, tab]); useEffect(() => () => abortRef.current?.abort(), []); const setAssistantMessage = useCallback((id: string, patch: Partial) => { setMessages((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item))); }, []); /** 一次「提问 + 回答」入列,返回回答消息 id 便于后续覆盖(工具结果 / 轮询回填)。 */ const pushExchange = useCallback( (query: string, assistant: Omit & { content: string }) => { const stamp = Date.now(); const replyId = `a-${stamp}`; setMessages((prev) => [ ...prev, { id: `u-${stamp}`, role: "user", content: query }, { id: replyId, role: "assistant", ...assistant }, ]); return replyId; }, [] ); const upsertReply = useCallback((replyId: string, content: string) => { setMessages((prev) => { if (prev.some((item) => item.id === replyId)) { return prev.map((item) => (item.id === replyId ? { ...item, content } : item)); } return [ ...prev, { id: replyId, role: "assistant", content }, ]; }); }, []); /* ---------------------------------------------------------------- 对话 */ const send = useCallback( async (raw: string) => { const query = raw.trim(); if (!query || busy) return; const replyId = `a-${Date.now()}`; setMessages((prev) => [ ...prev, { id: `u-${Date.now()}`, role: "user", content: query }, { id: replyId, role: "assistant", content: "" }, ]); setInput(""); setStreaming(true); const controller = new AbortController(); abortRef.current = controller; try { /* 客户可选:不传 customer_id,后端自己从问题文本里认客户,认不出就走通用问答 */ const result = await assistantApi.chatOnce( { query, session_id: sessionId ?? undefined }, controller.signal ); const content = result.error || result.text || "助手本次未返回内容。"; const hint = result.error ? advisorErrorHint(result.error, result.errorCode) : null; /* 后端 chat/stream 没有 rebalance 分支,命中调仓类问题会返回未初始化错误,给出换工具的出口 */ const retryTool = result.error && result.error.includes("核心能力尚未初始化") ? suggestToolForError(query) : undefined; setMessages((prev) => prev.map((item) => item.id === replyId ? { ...item, content, intent: result.intent, draftId: result.draftId, error: !!result.error, traceId: result.traceId, hint: hint ?? undefined, retryTool, retryQuery: retryTool ? query : undefined, } : item ) ); if (result.draftId) void loadDrafts(); } catch (error) { const aborted = (error as Error)?.name === "AbortError"; if (aborted) { /* 取消提问:撤掉占位的助手回复,用户消息保留 */ setMessages((prev) => prev.filter((item) => item.id !== replyId)); } else { setMessages((prev) => prev.map((item) => item.id === replyId ? { ...item, content: errMessage(error, "助手请求失败,请稍后重试"), error: true } : item ) ); } } finally { setStreaming(false); abortRef.current = null; } }, [busy, loadDrafts, sessionId] ); /* ---------------------------------------------------------------- 工具 */ const openTable = useCallback((query: string, snapshot: DataTableSnapshot) => { setTableModal({ title: query.length > 40 ? `${query.slice(0, 40)}…` : query, query, page: 1, pageSize: TABLE_PAGE_SIZE, loading: false, error: null, columns: snapshot.columns, rows: snapshot.rows, truncated: snapshot.truncated, warnings: snapshot.warnings, }); }, []); const reloadTable = useCallback( async (page: number) => { if (!tableModal) return; const { query, pageSize } = tableModal; setTableModal((prev) => (prev ? { ...prev, page, loading: true, error: null } : prev)); try { const result = await assistantApi.dataQuery({ query, session_id: sessionId ?? undefined, page, page_size: pageSize, }); setTableModal((prev) => prev ? { ...prev, page, loading: false, columns: result.columns ?? [], rows: result.rows ?? [], truncated: !!result.truncated, warnings: result.warnings ?? [], } : prev ); } catch (error) { setTableModal((prev) => prev ? { ...prev, loading: false, error: errMessage(error, "分页查询失败") } : prev ); } }, [sessionId, tableModal] ); const runTool = useCallback( async (tool: ToolKey, override?: string) => { const query = (override ?? input).trim(); if (!query) { toast("请先在输入框描述你的问题", "error"); return; } if (tool === "fund-analysis" && !hasFundCode(query)) { toast("基金分析需要基金代码,请写完整代码(形如 F000001)", "error"); return; } const controller = new AbortController(); abortRef.current = controller; setPending(true); /* 气泡里的「换个工具重试」用的是消息自带的问题,不该清掉输入框里正在写的内容 */ if (!override) setInput(""); try { if (tool === "data-query") { const result = await assistantApi.dataQuery({ query, session_id: sessionId ?? undefined, page: 1, page_size: TABLE_PAGE_SIZE, }); const snapshot = toSnapshot(result); pushExchange(query, { content: result.answer ?? result.summary ?? "查询完成,无文本摘要。", intent: "data_query", traceId: result.trace_id, table: snapshot, tableQuery: query, }); } else if (tool === "fund-analysis") { const result = await assistantApi.fundAnalysis(query); const items = fundItems(result); pushExchange(query, { content: items.map(fundText).join("\n\n") || "未返回分析结果。", intent: "fund_analysis", }); } else if (tool === "talk-script") { const result = await assistantApi.generateTalkScript(query); pushExchange(query, { content: result.content ?? "未生成话术。", intent: "dialogue-script", scene: result.scene_type, }); } else { /* 调仓是异步受理:先记下已有草稿,再轮询列表等新草稿落库 */ const baseline = draftsRef.current.map(draftKey).filter(Boolean); const result = await assistantApi.runRebalance(query); if (!result.accepted) { toast("调仓任务未受理", "error"); return; } const replyId = pushExchange(query, { content: `调仓任务已受理(状态 ${result.status ?? "queued"}),正在后台生成草稿…`, intent: "rebalance", }); setRebalancePending(true); try { const draft = await waitForNewDraft({ excludeDraftIds: baseline, intent: "rebalance", timeoutMs: REBALANCE_WAIT_MS, signal: controller.signal, }); await loadDrafts(); if (draft) { const id = draftKey(draft); upsertReply( replyId, `调仓草稿已生成:${draft.title || "未命名草稿"}${id ? `(${id})` : ""}` ); setAssistantMessage(replyId, { draftId: id }); toast("调仓草稿已生成", "success"); } else { upsertReply( replyId, "调仓任务仍在后台生成中,可在「草稿」页签点刷新查看,或稍后重试。" ); } } catch (error) { if ((error as Error)?.name === "AbortError") { upsertReply(replyId, "已停止等待,草稿仍会在后台生成,可在「草稿」页签查看。"); } else { throw error; } } finally { setRebalancePending(false); } } } catch (error) { if ((error as Error)?.name === "AbortError") { /* 主动取消:不打扰 */ } else { /* 工具失败也带「下一步怎么做」,最常见的是问题里没写清客户(40001) */ const message = errMessage(error, "工具调用失败"); const code = error instanceof ApiError ? error.code : undefined; const hint = advisorErrorHint(message, code); toast(hint && !message.includes(hint) ? `${message}(${hint})` : message, "error"); } } finally { setPending(false); abortRef.current = null; } }, [input, loadDrafts, pushExchange, sessionId, setAssistantMessage, toast, upsertReply] ); /* ---------------------------------------------------------------- 会话 */ /** * 打开抽屉 = 建立会话。投顾侧没有 session/create,session_id 由前端生成, * 后端按 (advisor_id, session_id) 惰性建立上下文。 */ 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); setPending(false); setSessionId(null); }, []); /** 抽屉内「结束会话」:不等关闭,立即换一个全新会话。 */ const endSession = useCallback(() => { abortRef.current?.abort(); setMessages([]); setInput(""); setStreaming(false); setPending(false); setSessionId(newSessionId()); toast("会话已关闭,已开启新的对话上下文", "success"); }, [toast]); /* ---------------------------------------------------------------- 草稿 */ const openDraft = useCallback( async (draftId: string) => { try { const draft = await assistantApi.draft(draftId); setActiveDraft(draft); setDraftDraft(draft.content ?? ""); } catch (error) { toast(errMessage(error, "草稿详情加载失败"), "error"); } }, [toast] ); const saveDraft = useCallback(async () => { const draftId = activeDraft?.draft_id ?? activeDraft?.id; if (!draftId) return; setSaving(true); try { await assistantApi.saveDraft(draftId, { content: draftDraft }); toast("草稿已保存", "success"); setActiveDraft(null); await loadDrafts(); } catch (error) { toast(errMessage(error, "保存失败"), "error"); } finally { setSaving(false); } }, [activeDraft, draftDraft, loadDrafts, toast]); const discardDraft = useCallback( async (draftId: string) => { try { await assistantApi.operateDraft(draftId, "discard"); toast("草稿已废弃", "success"); await loadDrafts(); } catch (error) { toast(errMessage(error, "废弃失败"), "error"); } }, [loadDrafts, toast] ); /* ---------------------------------------------------------------- 渲染 */ const tableHasNext = !!tableModal && tableModal.rows.length >= tableModal.pageSize; return ( <> {!open && ( )} {open && ( <>