"use client"; import { AlertCircle, BarChart3, Bot, 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, Select, cn, formatTime, statusTone, useToast, } from "@/components/admin/ui"; import { adminApi, errMessage } from "@/lib/admin-api"; import { assistantApi, newSessionId, type AgentDraft } from "@/lib/assistant-api"; import type { Customer } from "@/lib/admin-api"; interface ChatMessage { id: string; role: "user" | "assistant"; content: string; intent?: string; draftId?: string; error?: boolean; } const QUICK_TOOLS = [ { key: "data-query", label: "查客户数据", icon: Search, hint: "自然语言查询授权范围内的客户数据" }, { key: "fund-analysis", label: "基金分析", icon: BarChart3, hint: "问题里需带上基金代码,如 000001" }, { key: "talk-script", label: "生成话术", icon: MessageSquareQuote, hint: "按场景生成合规话术草稿" }, { key: "rebalance", label: "调仓再平衡", icon: RefreshCw, hint: "异步生成调仓建议草稿" }, ] as const; type ToolKey = (typeof QUICK_TOOLS)[number]["key"]; /** * 投顾助手:右下角常驻入口。 * 悬浮球打开抽屉后可直接对话、调用 Agent 工具、管理草稿; * 「结束会话」会中止生成并重置 session,历史不再延续到下一次对话。 */ export function AssistantLauncher() { const toast = useToast(); const [open, setOpen] = useState(false); const [tab, setTab] = useState<"chat" | "drafts">("chat"); const [sessionId, setSessionId] = useState(() => newSessionId()); const [customers, setCustomers] = useState([]); const [customerId, setCustomerId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [streaming, setStreaming] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); const [drafts, setDrafts] = useState([]); const [draftsLoading, setDraftsLoading] = useState(false); const [activeDraft, setActiveDraft] = useState(null); const [draftDraft, setDraftDraft] = useState(""); const [saving, setSaving] = useState(false); const abortRef = useRef(null); const scrollRef = useRef(null); const [elapsed, setElapsed] = useState(0); /* 后端是整包输出,等待期间给个计时,避免看起来像卡死 */ useEffect(() => { if (!streaming) { setElapsed(0); return; } const timer = setInterval(() => setElapsed((value) => value + 1), 1000); return () => clearInterval(timer); }, [streaming]); const sessionTag = useMemo(() => sessionId.slice(-6), [sessionId]); /* 客户列表只用于会话上下文选择,失败不阻断助手 */ useEffect(() => { void (async () => { try { const page = await adminApi.customers({ page: 1, page_size: 100 }); setCustomers(page.items ?? []); } catch { /* 静默 */ } })(); }, []); /* 会话 ID 变化时恢复该会话的历史;新会话为空属正常,不报错。 */ useEffect(() => { let alive = true; void (async () => { setHistoryLoading(true); setHistoryError(null); try { const history = await assistantApi.sessionHistory(sessionId); if (!alive) return; setMessages( (history ?? []) .filter((item) => item?.content) .map((item, index) => ({ id: `h-${index}`, role: item.role === "user" ? "user" : "assistant", content: String(item.content ?? ""), })) ); } catch (error) { if (!alive) return; setHistoryError(errMessage(error, "会话历史加载失败")); } finally { if (alive) setHistoryLoading(false); } })(); return () => { alive = false; }; }, [sessionId]); const loadDrafts = useCallback(async () => { setDraftsLoading(true); try { const page = await assistantApi.drafts({ page: 1, page_size: 20 }); setDrafts(page.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 pushPair = useCallback((query: string, answer: string, intent?: string) => { setMessages((prev) => [ ...prev, { id: `u-${Date.now()}`, role: "user", content: query }, { id: `a-${Date.now()}`, role: "assistant", content: answer, intent }, ]); }, []); const send = useCallback( async (raw: string) => { const query = raw.trim(); if (!query || streaming) 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 { const result = await assistantApi.chatOnce( { query, session_id: sessionId, scope: customerId ? "customer" : "advisor", customer_id: customerId, }, controller.signal ); const content = result.error || result.text || "助手本次未返回内容。"; setMessages((prev) => prev.map((item) => item.id === replyId ? { ...item, content, intent: result.intent, draftId: result.draftId, error: !!result.error, } : 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; } }, [customerId, loadDrafts, sessionId, streaming] ); const runTool = useCallback( async (tool: ToolKey) => { const query = input.trim(); if (!query) { toast("请先在输入框描述你的问题", "error"); return; } if (tool === "rebalance" && !customerId) { toast("调仓需要指定客户,请先在上方选择客户", "error"); return; } setStreaming(true); try { if (tool === "data-query") { const result = await assistantApi.dataQuery({ query, session_id: sessionId }); pushPair(query, result.answer ?? result.summary ?? "查询完成,无文本摘要。", "data_query"); } else if (tool === "fund-analysis") { const result = await assistantApi.fundAnalysis(query); pushPair( query, result.analysis_text ?? (Array.isArray(result.items) ? JSON.stringify(result.items, null, 2) : "未返回结果"), "fund_analysis" ); } else if (tool === "talk-script") { const result = await assistantApi.generateTalkScript(query); pushPair(query, result.script ?? result.content ?? "未生成话术。", "dialogue-script"); } else { const result = await assistantApi.runRebalance(query); toast( result.accepted ? "调仓任务已受理,草稿稍后生成" : "调仓任务未受理", result.accepted ? "success" : "error" ); void loadDrafts(); } setInput(""); } catch (error) { toast(errMessage(error, "工具调用失败"), "error"); } finally { setStreaming(false); } }, [customerId, input, loadDrafts, pushPair, sessionId, toast] ); /** 结束会话:中止生成、清空上下文并启用新的 session_id。 */ const endSession = useCallback(() => { abortRef.current?.abort(); setMessages([]); setInput(""); setStreaming(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] ); return ( <> {!open && ( )} {open && ( <>
setOpen(false)} aria-hidden />