Files
Mutual_Fund/frontend/components/agent-chat.tsx
T

246 lines
9.1 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 { Bot, MessageCircle, RotateCw, Send, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { getToken } from "@/lib/auth";
import { chatAgentApi, type ChatAgentMode } from "@/lib/chat-agent-api";
import { USER_TYPE } from "@/lib/roles";
interface ChatMessage {
id: number;
role: "user" | "agent";
content: string;
}
const GREETING = "您好,我是华夏基金智能助手。您可以咨询基金产品、风险等级和净值信息。";
/**
* 选择用哪个 Agent:
* - 已登录客户 → /agent/client/*(能读本人持仓与交易)
* - 游客 / 员工 → /agent/customer/*(匿名客服,不需要 token)
* 探测失败一律退回匿名客服,不阻断用户。
*/
async function resolveMode(): Promise<ChatAgentMode> {
if (!getToken()) return "customer";
try {
const result = await apiFetch<{ user?: { user_type?: string | null } }>("/auth/me");
return result?.user?.user_type === USER_TYPE.CUSTOMER ? "client" : "customer";
} catch {
return "customer";
}
}
export function AgentChat() {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [connecting, setConnecting] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [mode, setMode] = useState<ChatAgentMode>("customer");
const [error, setError] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([
{ id: 1, role: "agent", content: GREETING },
]);
const abortRef = useRef<AbortController | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
}, [messages, connecting]);
/** 建立会话:探测 Agent 类型 → session/create 换取后端签发的 session_id。 */
const connect = useCallback(async () => {
setConnecting(true);
setError(null);
try {
const resolved = await resolveMode();
const session = await chatAgentApi.createSession(resolved);
setMode(resolved);
setSessionId(session.session_id);
} catch (err) {
setError(err instanceof Error ? err.message : "会话建立失败,请重试");
} finally {
setConnecting(false);
}
}, []);
/** 打开面板即建立会话(不是等用户第一次提问才建)。 */
const openChat = useCallback(() => {
setOpen(true);
if (!sessionId && !connecting) void connect();
}, [connect, connecting, sessionId]);
/** 关闭面板即结束会话:通知后端清理上下文与限流键,本地一并重置。 */
const closeChat = useCallback(async () => {
abortRef.current?.abort();
setOpen(false);
setInput("");
setError(null);
setMessages([{ id: Date.now(), role: "agent", content: GREETING }]);
const current = sessionId;
setSessionId(null);
if (!current) return;
try {
await chatAgentApi.endSession(mode, current);
} catch {
/* 会话清理失败不阻断用户 */
}
}, [mode, sessionId]);
const sendMessage = useCallback(async () => {
const content = input.trim();
if (!content || sending || connecting) return;
if (!sessionId) {
setError("会话尚未建立,请点击重试。");
return;
}
setInput("");
setError(null);
setMessages((prev) => [...prev, { id: Date.now(), role: "user", content }]);
setSending(true);
const controller = new AbortController();
abortRef.current = controller;
try {
const result = await chatAgentApi.chat(
mode,
{ session_id: sessionId, query: content },
controller.signal
);
setMessages((prev) => [
...prev,
{
id: Date.now() + 1,
role: "agent",
content: result.error || result.text || "暂时没有找到合适的回答,请稍后再试。",
},
]);
} catch (err) {
if ((err as Error)?.name === "AbortError") return;
setMessages((prev) => [
...prev,
{
id: Date.now() + 1,
role: "agent",
content: err instanceof Error ? err.message : "当前服务暂时不可用,请稍后再试。",
},
]);
} finally {
setSending(false);
abortRef.current = null;
}
}, [connecting, input, mode, sending, sessionId]);
const ready = !!sessionId;
return (
<div className="fixed bottom-3 right-7 z-50">
{open && (
<div
role="dialog"
aria-label="华夏基金智能助手"
className="mb-4 flex h-[520px] w-[380px] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"
>
<div className="flex items-center justify-between bg-[var(--brand-navy)] px-5 py-4 text-white">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[var(--brand-primary)]">
<Bot className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold">华夏基金智能助手</p>
<p className="mt-0.5 text-xs text-blue-100">
{mode === "client" ? "已接入您的账户数据" : "在线为您提供基金信息服务"}
</p>
</div>
</div>
<button
aria-label="关闭聊天框"
onClick={() => void closeChat()}
className="rounded-lg p-2 text-blue-100 transition hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
<div ref={listRef} className="flex-1 space-y-4 overflow-y-auto bg-slate-50 p-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[82%] rounded-2xl px-4 py-3 text-sm leading-6 ${
message.role === "user"
? "rounded-br-md bg-[var(--brand-primary)] text-white"
: "rounded-bl-md border border-slate-200 bg-white text-slate-700"
}`}
>
{message.content}
</div>
</div>
))}
{connecting && (
<div className="flex items-center gap-2 text-xs text-slate-400">
<RotateCw className="h-3 w-3 animate-spin" />
正在建立会话...
</div>
)}
{sending && !connecting && <div className="text-xs text-slate-400">正在整理信息...</div>}
</div>
{error && (
<div className="flex items-center justify-between gap-3 border-t border-rose-100 bg-rose-50 px-4 py-2.5">
<span className="text-xs text-rose-600">{error}</span>
{!ready && (
<button
onClick={() => void connect()}
disabled={connecting}
className="shrink-0 rounded-lg border border-rose-200 bg-white px-2.5 py-1 text-xs text-rose-600 transition hover:bg-rose-100 disabled:opacity-50"
>
重试
</button>
)}
</div>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void sendMessage();
}}
className="flex gap-2 border-t border-slate-200 bg-white p-3"
>
<input
value={input}
onChange={(event) => 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"
/>
<button
type="submit"
aria-label="发送消息"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--brand-primary)] text-white transition hover:bg-[var(--brand-primary-dark)] disabled:cursor-not-allowed disabled:opacity-50"
disabled={sending || connecting || !input.trim()}
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
)}
<button
aria-label={open ? "收起智能助手" : "打开智能助手"}
onClick={() => (open ? void closeChat() : openChat())}
className="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--brand-primary)] text-white shadow-lg shadow-indigo-900/25 transition hover:-translate-y-0.5 hover:bg-[var(--brand-primary-dark)]"
>
{open ? <X className="h-5 w-5" /> : <MessageCircle className="h-5 w-5" />}
</button>
</div>
);
}