Files
Mutual_Fund/frontend/components/admin/assistant-launcher.tsx
T

652 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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");
/* 会话在「打开抽屉」时才建立(见 openAssistant),关闭时丢弃,不在挂载时就占用上下文 */
const [sessionId, setSessionId] = useState<string | null>(null);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerId, setCustomerId] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [drafts, setDrafts] = useState<AgentDraft[]>([]);
const [draftsLoading, setDraftsLoading] = useState(false);
const [activeDraft, setActiveDraft] = useState<AgentDraft | null>(null);
const [draftDraft, setDraftDraft] = useState("");
const [saving, setSaving] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(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 ? sessionId.slice(-6) : "--------"), [sessionId]);
/* 客户列表只用于会话上下文选择,失败不阻断助手 */
useEffect(() => {
void (async () => {
try {
const page = await adminApi.customers({ page: 1, page_size: 100 });
setCustomers(page.items ?? []);
} catch {
/* 静默 */
}
})();
}, []);
/*
* 会话握手:打开抽屉(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);
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 ?? undefined,
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 ?? undefined });
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]
);
/**
* 打开抽屉 = 建立会话。
* 投顾 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([]);
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 && (
<button
onClick={openAssistant}
aria-label="打开投顾助手"
className="fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600 text-white shadow-lg shadow-indigo-600/30 transition-transform hover:scale-105 hover:bg-indigo-700"
>
<Bot className="h-6 w-6" />
{drafts.length > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
{drafts.length > 99 ? "99+" : drafts.length}
</span>
)}
</button>
)}
{open && (
<>
<div
className="fixed inset-0 z-40 bg-slate-900/10"
onClick={closeAssistant}
aria-hidden
/>
<aside className="fixed inset-y-0 right-0 z-50 flex w-full max-w-[560px] flex-col bg-white shadow-2xl">
<header className="flex items-center gap-3 border-b border-slate-200 px-5 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
<Bot className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-slate-900">投顾助手</p>
<p className="flex items-center gap-1.5 text-xs text-slate-400">
会话 #{sessionTag}
{streaming && (
<span className="flex items-center gap-1 text-indigo-500">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-indigo-500" />
处理中
</span>
)}
</p>
</div>
<Button size="sm" variant="ghost" onClick={endSession} title="结束当前会话并开启新会话">
<RefreshCw className="h-3.5 w-3.5" />
结束会话
</Button>
<button
onClick={closeAssistant}
aria-label="收起助手"
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<Minus className="h-4 w-4" />
</button>
</header>
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
<Select
value={customerId ? String(customerId) : ""}
onChange={(event) =>
setCustomerId(event.target.value ? Number(event.target.value) : null)
}
className="flex-1"
>
<option value="">未指定客户(通用问答)</option>
{customers.map((customer) => (
<option key={customer.customer_id} value={customer.customer_id}>
#{customer.customer_id} {customer.real_name ?? "未填写姓名"}
</option>
))}
</Select>
<div className="flex rounded-lg bg-slate-100 p-0.5">
{(["chat", "drafts"] as const).map((key) => (
<button
key={key}
onClick={() => setTab(key)}
className={cn(
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
tab === key ? "bg-white text-slate-900 shadow-sm" : "text-slate-500"
)}
>
{key === "chat" ? "对话" : `草稿${drafts.length ? ` (${drafts.length})` : ""}`}
</button>
))}
</div>
</div>
{tab === "chat" ? (
<>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto px-5 py-4">
{historyLoading && messages.length === 0 && (
<p className="py-8 text-center text-sm text-slate-400">正在建立会话...</p>
)}
{historyError && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">{historyError}</p>
)}
{!historyLoading && messages.length === 0 && (
<div className="flex h-full flex-col items-center justify-center px-6 text-center">
<Bot className="mb-3 h-9 w-9 text-slate-300" />
<p className="text-sm text-slate-500">
我可以帮你查询客户数据、分析基金、生成沟通话术和调仓建议。
</p>
<p className="mt-2 text-xs text-slate-400">
指定客户后,助手只能读取你名下已授权的客户数据。
</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={cn("flex gap-2.5", message.role === "user" && "flex-row-reverse")}
>
<div
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
message.role === "user"
? "bg-indigo-100 text-indigo-600"
: "bg-slate-100 text-slate-500"
)}
>
{message.role === "user" ? (
<User className="h-3.5 w-3.5" />
) : (
<Bot className="h-3.5 w-3.5" />
)}
</div>
<div
className={cn(
"max-w-[80%] rounded-xl px-3.5 py-2.5 text-sm leading-6",
message.role === "user"
? "bg-indigo-600 text-white"
: message.error
? "bg-red-50 text-red-600"
: "bg-slate-100 text-slate-700"
)}
>
{message.content ? (
<p className="whitespace-pre-wrap break-words">{message.content}</p>
) : (
<p className="flex items-center gap-1.5 text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
助手正在思考{elapsed > 0 ? ` ${elapsed}s` : ""}
</p>
)}
{message.intent && (
<p className="mt-1.5 text-xs opacity-70">意图:{message.intent}</p>
)}
</div>
</div>
))}
</div>
<div className="space-y-2.5 border-t border-slate-100 px-5 py-3.5">
<div className="flex flex-wrap gap-1.5">
{QUICK_TOOLS.map((tool) => (
<button
key={tool.key}
onClick={() => void runTool(tool.key)}
disabled={streaming}
title={tool.hint}
className="flex items-center gap-1 rounded-full border border-slate-200 px-2.5 py-1 text-xs text-slate-500 transition-colors hover:border-indigo-300 hover:text-indigo-600 disabled:opacity-50"
>
<tool.icon className="h-3 w-3" />
{tool.label}
</button>
))}
</div>
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send(input);
}
}}
rows={2}
placeholder="描述你的问题,回车发送(Shift+Enter 换行)"
className="max-h-32 flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm leading-6 text-slate-700 focus:border-indigo-400 focus:outline-none"
/>
{streaming ? (
<Button
variant="danger"
onClick={() => abortRef.current?.abort()}
aria-label="取消本次提问"
title="取消本次提问"
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button variant="primary" disabled={!input.trim()} onClick={() => void send(input)}>
<Send className="h-4 w-4" />
</Button>
)}
</div>
</div>
</>
) : (
<div className="flex-1 overflow-y-auto">
<div className="flex items-center justify-between px-5 pt-4">
<p className="text-xs text-slate-400">助手生成的建议草稿</p>
<Button size="sm" variant="ghost" onClick={() => void loadDrafts()} disabled={draftsLoading}>
<RefreshCw className={cn("h-3.5 w-3.5", draftsLoading && "animate-spin")} />
刷新
</Button>
</div>
{drafts.length === 0 ? (
<EmptyState text="暂无 Agent 草稿" />
) : (
<ul className="mt-2 divide-y divide-slate-100">
{drafts.map((draft) => {
const id = draft.draft_id ?? draft.id ?? "";
return (
<li key={id} className="px-5 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-800">
{draft.title || "未命名草稿"}
</p>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
{draft.content || "(无正文)"}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{draft.intent && <Badge tone="info">{draft.intent}</Badge>}
{draft.status && (
<Badge tone={statusTone(draft.status)}>{draft.status}</Badge>
)}
<span className="text-xs text-slate-400">
{formatTime(draft.update_time ?? draft.create_time)}
</span>
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button size="sm" variant="ghost" onClick={() => void openDraft(id)}>
编辑
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void discardDraft(id)}
aria-label="废弃草稿"
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
)}
</aside>
</>
)}
<Modal
open={!!activeDraft}
title="编辑草稿"
onClose={() => setActiveDraft(null)}
footer={
<>
<Button onClick={() => setActiveDraft(null)}>取消</Button>
<Button variant="primary" loading={saving} onClick={() => void saveDraft()}>
保存
</Button>
</>
}
>
<p className="mb-3 text-xs text-slate-500">
草稿 ID:{activeDraft?.draft_id ?? activeDraft?.id ?? "--"}
</p>
<textarea
value={draftDraft}
onChange={(event) => setDraftDraft(event.target.value)}
rows={14}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm leading-6 text-slate-700 focus:border-indigo-400 focus:outline-none"
/>
<p className="mt-3 flex items-start gap-2 text-xs text-amber-600">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
保存后会按客户风险等级做合规校验,未通过的修改会被后端拒绝。
</p>
</Modal>
</>
);
}