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

1063 lines
42 KiB
TypeScript
Raw Normal View History

2026-09-14 20:31:06 +08:00
"use client";
import {
AlertCircle,
BarChart3,
Bot,
2026-09-15 16:08:44 +08:00
FileText,
Info,
2026-09-14 20:31:06 +08:00
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";
2026-09-15 16:08:44 +08:00
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<string, unknown>[];
truncated: boolean;
warnings: string[];
}
2026-09-14 20:31:06 +08:00
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
intent?: string;
2026-09-15 16:08:44 +08:00
scene?: string;
2026-09-14 20:31:06 +08:00
draftId?: string;
error?: boolean;
2026-09-15 16:08:44 +08:00
traceId?: string;
/** 错误条上的「下一步怎么做」。 */
hint?: string;
/** 错误条上的「换个工具重试」:工具名 + 原始问题。 */
retryTool?: ToolKey;
retryQuery?: string;
/** 数据查询带回来的结果集,用于气泡里的预览表和详情弹窗。 */
table?: DataTableSnapshot;
/** 生成这张表的原始问题(原样送给后端,翻页重查时保持一致)。 */
tableQuery?: string;
2026-09-14 20:31:06 +08:00
}
const QUICK_TOOLS = [
2026-09-15 16:08:44 +08:00
{
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: "异步生成调仓草稿,仅限已签约客户",
},
2026-09-14 20:31:06 +08:00
] as const;
type ToolKey = (typeof QUICK_TOOLS)[number]["key"];
2026-09-15 16:08:44 +08:00
/** 结果集分页大小:后端 /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");
}
2026-09-14 20:31:06 +08:00
/**
* 投顾助手:右下角常驻入口。
2026-09-15 16:08:44 +08:00
* 悬浮球打开抽屉后可直接对话、调用 Agent 工具、管理草稿。
*
* 客户不是必填项:/chat/stream 的 customer_id 可选、另外四个工具接口没有该字段,
* 后端统一从问题文本里认客户(_resolve_customer_from_query),所以前端不做客户选择,
* 只在用户没写明客户导致 40001 时给出可操作的提示。
*
* 另外 /rebalance/run 是异步受理,草稿要靠轮询 /draft/list 才拿得到。
2026-09-14 20:31:06 +08:00
*/
export function AssistantLauncher() {
const toast = useToast();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"chat" | "drafts">("chat");
2026-09-14 20:58:47 +08:00
/* 会话在「打开抽屉」时才建立(见 openAssistant),关闭时丢弃,不在挂载时就占用上下文 */
const [sessionId, setSessionId] = useState<string | null>(null);
2026-09-14 20:31:06 +08:00
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
2026-09-15 16:08:44 +08:00
/** streaming = SSE 对话在途;pending = 工具调用 / 草稿轮询在途。 */
2026-09-14 20:31:06 +08:00
const [streaming, setStreaming] = useState(false);
2026-09-15 16:08:44 +08:00
const [pending, setPending] = useState(false);
const busy = streaming || pending;
2026-09-14 20:31:06 +08:00
const [drafts, setDrafts] = useState<AgentDraft[]>([]);
const [draftsLoading, setDraftsLoading] = useState(false);
2026-09-15 16:08:44 +08:00
const [rebalancePending, setRebalancePending] = useState(false);
2026-09-14 20:31:06 +08:00
const [activeDraft, setActiveDraft] = useState<AgentDraft | null>(null);
const [draftDraft, setDraftDraft] = useState("");
const [saving, setSaving] = useState(false);
2026-09-15 16:08:44 +08:00
const [tableModal, setTableModal] = useState<{
title: string;
query: string;
page: number;
pageSize: number;
loading: boolean;
error: string | null;
columns: string[];
rows: Record<string, unknown>[];
truncated: boolean;
warnings: string[];
} | null>(null);
2026-09-14 20:31:06 +08:00
const abortRef = useRef<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
2026-09-15 16:08:44 +08:00
const draftsRef = useRef<AgentDraft[]>([]);
2026-09-14 20:31:06 +08:00
const [elapsed, setElapsed] = useState(0);
/* 后端是整包输出,等待期间给个计时,避免看起来像卡死 */
useEffect(() => {
2026-09-15 16:08:44 +08:00
if (!busy) {
2026-09-14 20:31:06 +08:00
setElapsed(0);
return;
}
const timer = setInterval(() => setElapsed((value) => value + 1), 1000);
return () => clearInterval(timer);
2026-09-15 16:08:44 +08:00
}, [busy]);
2026-09-14 20:31:06 +08:00
2026-09-14 20:58:47 +08:00
const sessionTag = useMemo(() => (sessionId ? sessionId.slice(-6) : "--------"), [sessionId]);
2026-09-14 20:31:06 +08:00
2026-09-14 20:58:47 +08:00
/*
2026-09-15 16:08:44 +08:00
* 投顾 Agent 没有独立的 session/create:后端按 (advisor_id, session_id) 惰性建立上下文,
* session_id 完全由前端生成,服务端不可能存在它的历史记录,
* 所以打开抽屉时不再多发一次必然为空的 /session/{id}/history(省一次鉴权往返)。
* 一旦后端补上会话持久化,再在这里补一次 hydrate 即可。
2026-09-14 20:58:47 +08:00
*/
2026-09-14 20:31:06 +08:00
const loadDrafts = useCallback(async () => {
setDraftsLoading(true);
try {
2026-09-15 16:08:44 +08:00
/* 不带 customer_id:后端默认返回当前投顾名下的全部草稿 */
2026-09-14 20:31:06 +08:00
const page = await assistantApi.drafts({ page: 1, page_size: 20 });
2026-09-15 16:08:44 +08:00
const items = page.items ?? [];
draftsRef.current = items;
setDrafts(items);
2026-09-14 20:31:06 +08:00
} 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(), []);
2026-09-15 16:08:44 +08:00
const setAssistantMessage = useCallback((id: string, patch: Partial<ChatMessage>) => {
setMessages((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
2026-09-14 20:31:06 +08:00
}, []);
2026-09-15 16:08:44 +08:00
/** 一次「提问 + 回答」入列,返回回答消息 id 便于后续覆盖(工具结果 / 轮询回填)。 */
const pushExchange = useCallback(
(query: string, assistant: Omit<ChatMessage, "id" | "role" | "content"> & { 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 },
];
});
}, []);
/* ---------------------------------------------------------------- 对话 */
2026-09-14 20:31:06 +08:00
const send = useCallback(
async (raw: string) => {
const query = raw.trim();
2026-09-15 16:08:44 +08:00
if (!query || busy) return;
2026-09-14 20:31:06 +08:00
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 {
2026-09-15 16:08:44 +08:00
/* 客户可选:不传 customer_id,后端自己从问题文本里认客户,认不出就走通用问答 */
2026-09-14 20:31:06 +08:00
const result = await assistantApi.chatOnce(
2026-09-15 16:08:44 +08:00
{ query, session_id: sessionId ?? undefined },
2026-09-14 20:31:06 +08:00
controller.signal
);
const content = result.error || result.text || "助手本次未返回内容。";
2026-09-15 16:08:44 +08:00
const hint = result.error ? advisorErrorHint(result.error, result.errorCode) : null;
/* 后端 chat/stream 没有 rebalance 分支,命中调仓类问题会返回未初始化错误,给出换工具的出口 */
const retryTool =
result.error && result.error.includes("核心能力尚未初始化")
? suggestToolForError(query)
: undefined;
2026-09-14 20:31:06 +08:00
setMessages((prev) =>
prev.map((item) =>
item.id === replyId
? {
...item,
content,
intent: result.intent,
draftId: result.draftId,
error: !!result.error,
2026-09-15 16:08:44 +08:00
traceId: result.traceId,
hint: hint ?? undefined,
retryTool,
retryQuery: retryTool ? query : undefined,
2026-09-14 20:31:06 +08:00
}
: 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;
}
},
2026-09-15 16:08:44 +08:00
[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]
2026-09-14 20:31:06 +08:00
);
const runTool = useCallback(
2026-09-15 16:08:44 +08:00
async (tool: ToolKey, override?: string) => {
const query = (override ?? input).trim();
2026-09-14 20:31:06 +08:00
if (!query) {
toast("请先在输入框描述你的问题", "error");
return;
}
2026-09-15 16:08:44 +08:00
if (tool === "fund-analysis" && !hasFundCode(query)) {
toast("基金分析需要基金代码,请写完整代码(形如 F000001)", "error");
2026-09-14 20:31:06 +08:00
return;
}
2026-09-15 16:08:44 +08:00
const controller = new AbortController();
abortRef.current = controller;
setPending(true);
/* 气泡里的「换个工具重试」用的是消息自带的问题,不该清掉输入框里正在写的内容 */
if (!override) setInput("");
2026-09-14 20:31:06 +08:00
try {
if (tool === "data-query") {
2026-09-15 16:08:44 +08:00
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,
});
2026-09-14 20:31:06 +08:00
} else if (tool === "fund-analysis") {
const result = await assistantApi.fundAnalysis(query);
2026-09-15 16:08:44 +08:00
const items = fundItems(result);
pushExchange(query, {
content: items.map(fundText).join("\n\n") || "未返回分析结果。",
intent: "fund_analysis",
});
2026-09-14 20:31:06 +08:00
} else if (tool === "talk-script") {
const result = await assistantApi.generateTalkScript(query);
2026-09-15 16:08:44 +08:00
pushExchange(query, {
content: result.content ?? "未生成话术。",
intent: "dialogue-script",
scene: result.scene_type,
});
2026-09-14 20:31:06 +08:00
} else {
2026-09-15 16:08:44 +08:00
/* 调仓是异步受理:先记下已有草稿,再轮询列表等新草稿落库 */
const baseline = draftsRef.current.map(draftKey).filter(Boolean);
2026-09-14 20:31:06 +08:00
const result = await assistantApi.runRebalance(query);
2026-09-15 16:08:44 +08:00
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);
}
2026-09-14 20:31:06 +08:00
}
} catch (error) {
2026-09-15 16:08:44 +08:00
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");
}
2026-09-14 20:31:06 +08:00
} finally {
2026-09-15 16:08:44 +08:00
setPending(false);
abortRef.current = null;
2026-09-14 20:31:06 +08:00
}
},
2026-09-15 16:08:44 +08:00
[input, loadDrafts, pushExchange, sessionId, setAssistantMessage, toast, upsertReply]
2026-09-14 20:31:06 +08:00
);
2026-09-15 16:08:44 +08:00
/* ---------------------------------------------------------------- 会话 */
2026-09-14 20:58:47 +08:00
/**
2026-09-15 16:08:44 +08:00
* 打开抽屉 = 建立会话。投顾侧没有 session/create,session_id 由前端生成,
* 后端按 (advisor_id, session_id) 惰性建立上下文。
2026-09-14 20:58:47 +08:00
*/
const openAssistant = useCallback(() => {
setOpen(true);
setSessionId((current) => current ?? newSessionId());
}, []);
/**
* 关闭抽屉 = 结束会话:中止生成、清空消息并丢弃上下文。
2026-09-15 16:08:44 +08:00
* 后端未提供 session/end,结束只能在前端完成(下次打开是新的 session_id)。
2026-09-14 20:58:47 +08:00
*/
const closeAssistant = useCallback(() => {
abortRef.current?.abort();
setOpen(false);
setMessages([]);
setInput("");
setStreaming(false);
2026-09-15 16:08:44 +08:00
setPending(false);
2026-09-14 20:58:47 +08:00
setSessionId(null);
}, []);
2026-09-15 16:08:44 +08:00
/** 抽屉内「结束会话」:不等关闭,立即换一个全新会话。 */
2026-09-14 20:31:06 +08:00
const endSession = useCallback(() => {
abortRef.current?.abort();
setMessages([]);
setInput("");
setStreaming(false);
2026-09-15 16:08:44 +08:00
setPending(false);
2026-09-14 20:31:06 +08:00
setSessionId(newSessionId());
toast("会话已关闭,已开启新的对话上下文", "success");
}, [toast]);
2026-09-15 16:08:44 +08:00
/* ---------------------------------------------------------------- 草稿 */
2026-09-14 20:31:06 +08:00
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]
);
2026-09-15 16:08:44 +08:00
/* ---------------------------------------------------------------- 渲染 */
const tableHasNext = !!tableModal && tableModal.rows.length >= tableModal.pageSize;
2026-09-14 20:31:06 +08:00
return (
<>
{!open && (
<button
2026-09-14 20:58:47 +08:00
onClick={openAssistant}
2026-09-14 20:31:06 +08:00
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"
2026-09-14 20:58:47 +08:00
onClick={closeAssistant}
2026-09-14 20:31:06 +08:00
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}
2026-09-15 16:08:44 +08:00
{busy && (
2026-09-14 20:31:06 +08:00
<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" />
2026-09-15 16:08:44 +08:00
处理中{elapsed > 0 ? ` ${elapsed}s` : ""}
2026-09-14 20:31:06 +08:00
</span>
)}
</p>
</div>
<Button size="sm" variant="ghost" onClick={endSession} title="结束当前会话并开启新会话">
<RefreshCw className="h-3.5 w-3.5" />
结束会话
</Button>
<button
2026-09-14 20:58:47 +08:00
onClick={closeAssistant}
2026-09-14 20:31:06 +08:00
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>
2026-09-15 16:08:44 +08:00
<div className="flex items-center justify-end border-b border-slate-100 px-5 py-2">
2026-09-14 20:31:06 +08:00
<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">
2026-09-15 16:08:44 +08:00
{messages.length === 0 && (
2026-09-14 20:31:06 +08:00
<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">
2026-09-15 16:08:44 +08:00
直接对话会自动识别意图;查客户数据、生成调仓方案时,请在问题里写明客户编号或姓名。
2026-09-14 20:31:06 +08:00
</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(
2026-09-15 16:08:44 +08:00
"min-w-0 max-w-[80%] rounded-xl px-3.5 py-2.5 text-sm leading-6",
2026-09-14 20:31:06 +08:00
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>
)}
2026-09-15 16:08:44 +08:00
{(message.intent || message.scene) && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 opacity-80">
{message.intent && <Badge tone="info">{intentLabel(message.intent)}</Badge>}
{message.scene && <Badge>{sceneLabel(message.scene)}</Badge>}
</div>
)}
{message.table && (
<div className="mt-2 overflow-hidden rounded-lg border border-slate-200 bg-white">
<div className="max-h-44 overflow-auto">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 bg-slate-50">
<tr>
{message.table.columns.map((column) => (
<th
key={column}
className="whitespace-nowrap px-2 py-1.5 text-left font-medium text-slate-500"
>
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{message.table.rows.slice(0, 10).map((row, index) => (
<tr key={index}>
{message.table?.columns.map((column) => (
<td
key={column}
className="whitespace-nowrap px-2 py-1.5 text-slate-600"
>
{cellText(row[column])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-2 border-t border-slate-100 bg-slate-50 px-2 py-1.5 text-[11px] text-slate-500">
<span>
本页 {message.table.rows.length} 条
{message.table.rows.length > 10 ? "(预览前 10 条)" : ""}
{message.table.truncated ? " · 结果被截断" : ""}
</span>
{message.tableQuery && (
<button
onClick={() => openTable(message.tableQuery!, message.table!)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-indigo-600 hover:bg-indigo-50"
>
<FileText className="h-3 w-3" />
查看数据表
</button>
)}
</div>
</div>
)}
{message.hint && (
<p className="mt-1.5 flex items-start gap-1.5 text-xs text-amber-600">
<Info className="mt-0.5 h-3 w-3 shrink-0" />
{message.hint}
</p>
)}
{message.retryTool && message.retryQuery && (
<button
disabled={busy}
onClick={() => void runTool(message.retryTool!, message.retryQuery)}
className="mt-1.5 rounded-md border border-red-200 bg-white px-2 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
>
改用「{QUICK_TOOLS.find((tool) => tool.key === message.retryTool)?.label}」重试
</button>
)}
{message.draftId && (
<button
onClick={() => void openDraft(message.draftId!)}
className="mt-1.5 flex items-center gap-1 rounded-md border border-indigo-200 bg-white px-2 py-1 text-xs text-indigo-600 transition-colors hover:bg-indigo-50"
>
<FileText className="h-3 w-3" />
查看草稿 {message.draftId}
</button>
)}
{message.traceId && (
<p className="mt-1 truncate text-[10px] text-slate-400" title={message.traceId}>
trace: {message.traceId}
</p>
2026-09-14 20:31:06 +08:00
)}
</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)}
2026-09-15 16:08:44 +08:00
disabled={busy}
2026-09-14 20:31:06 +08:00
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}
2026-09-15 16:08:44 +08:00
disabled={busy}
2026-09-14 20:31:06 +08:00
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send(input);
}
}}
rows={2}
2026-09-15 16:08:44 +08:00
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 disabled:bg-slate-50"
2026-09-14 20:31:06 +08:00
/>
2026-09-15 16:08:44 +08:00
{busy ? (
2026-09-14 20:31:06 +08:00
<Button
variant="danger"
onClick={() => abortRef.current?.abort()}
2026-09-15 16:08:44 +08:00
aria-label="取消本次请求"
title="取消本次请求"
2026-09-14 20:31:06 +08:00
>
<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">
2026-09-15 16:08:44 +08:00
<p className="text-xs text-slate-400">
助手草稿
{rebalancePending && (
<span className="ml-2 text-indigo-500">
<Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
调仓草稿生成中
</span>
)}
</p>
2026-09-14 20:31:06 +08:00
<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) => {
2026-09-15 16:08:44 +08:00
const id = draftKey(draft);
2026-09-14 20:31:06 +08:00
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">
2026-09-15 16:08:44 +08:00
{draft.customer_id != null && (
<Badge>客户 #{draft.customer_id}</Badge>
)}
{draft.intent && (
<Badge tone="info">{intentLabel(draft.intent)}</Badge>
)}
2026-09-14 20:31:06 +08:00
{draft.status && (
2026-09-15 16:08:44 +08:00
<Badge tone={statusTone(draft.status)}>
{draftStatusLabel(draft.status)}
</Badge>
2026-09-14 20:31:06 +08:00
)}
<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>
</>
)}
2026-09-15 16:08:44 +08:00
{/* 数据表明细:抽屉太窄,全量结果和翻页放在独立弹层 */}
{tableModal && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-6">
<div className="absolute inset-0 bg-slate-900/40" onClick={() => setTableModal(null)} aria-hidden />
<div className="relative z-10 flex max-h-[80vh] w-full max-w-4xl flex-col rounded-xl border border-slate-200 bg-white shadow-xl">
<header className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-slate-900">查询结果 · {tableModal.title}</h3>
<p className="mt-0.5 text-xs text-slate-400">
共 {tableModal.rows.length} 条
{tableModal.truncated ? " · 结果被截断" : ""}
{tableModal.warnings.length > 0 ? ` · ${tableModal.warnings.join("、")}` : ""}
</p>
</div>
<button
onClick={() => setTableModal(null)}
aria-label="关闭"
className="rounded p-1 text-slate-400 hover:bg-slate-100"
>
<Minus className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-auto px-5 py-4">
{tableModal.error && (
<p className="mb-3 flex items-center gap-2 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
<AlertCircle className="h-3.5 w-3.5" />
{tableModal.error}
</p>
)}
{tableModal.columns.length === 0 ? (
<EmptyState text="本页没有数据" />
) : (
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 bg-slate-50">
<tr>
{tableModal.columns.map((column) => (
<th
key={column}
className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500"
>
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{tableModal.rows.map((row, index) => (
<tr key={index} className="hover:bg-slate-50">
{tableModal.columns.map((column) => (
<td key={column} className="whitespace-nowrap px-3 py-2 text-slate-700">
{cellText(row[column])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<footer className="flex items-center justify-between border-t border-slate-100 px-5 py-3 text-xs text-slate-500">
<span className="flex items-center gap-2">
{tableModal.loading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
第 {tableModal.page} 页 · 每页 {tableModal.pageSize} 条
</span>
<div className="flex gap-2">
<Button
size="sm"
disabled={tableModal.page <= 1 || tableModal.loading}
onClick={() => void reloadTable(tableModal.page - 1)}
>
上一页
</Button>
<Button
size="sm"
disabled={!tableHasNext || tableModal.loading}
onClick={() => void reloadTable(tableModal.page + 1)}
>
下一页
</Button>
</div>
</footer>
</div>
</div>
)}
2026-09-14 20:31:06 +08:00
<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 ?? "--"}
2026-09-15 16:08:44 +08:00
{activeDraft?.customer_id != null ? ` · 客户 #${activeDraft.customer_id}` : ""}
2026-09-14 20:31:06 +08:00
</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>
</>
);
}