Files

1063 lines
42 KiB
TypeScript
Raw Permalink 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,
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<string, unknown>[];
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<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
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<AgentDraft[]>([]);
const [draftsLoading, setDraftsLoading] = useState(false);
const [rebalancePending, setRebalancePending] = useState(false);
const [activeDraft, setActiveDraft] = useState<AgentDraft | null>(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<string, unknown>[];
truncated: boolean;
warnings: string[];
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const draftsRef = useRef<AgentDraft[]>([]);
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<ChatMessage>) => {
setMessages((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}, []);
/** 一次「提问 + 回答」入列,返回回答消息 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 },
];
});
}, []);
/* ---------------------------------------------------------------- 对话 */
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 && (
<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}
{busy && (
<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" />
处理中{elapsed > 0 ? ` ${elapsed}s` : ""}
</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 justify-end border-b border-slate-100 px-5 py-2">
<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">
{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(
"min-w-0 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 || 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>
)}
</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={busy}
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}
disabled={busy}
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 disabled:bg-slate-50"
/>
{busy ? (
<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">
助手草稿
{rebalancePending && (
<span className="ml-2 text-indigo-500">
<Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
调仓草稿生成中
</span>
)}
</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 = draftKey(draft);
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.customer_id != null && (
<Badge>客户 #{draft.customer_id}</Badge>
)}
{draft.intent && (
<Badge tone="info">{intentLabel(draft.intent)}</Badge>
)}
{draft.status && (
<Badge tone={statusTone(draft.status)}>
{draftStatusLabel(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>
</>
)}
{/* 数据表明细:抽屉太窄,全量结果和翻页放在独立弹层 */}
{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>
)}
<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 ?? "--"}
{activeDraft?.customer_id != null ? ` · 客户 #${activeDraft.customer_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>
</>
);
}