1063 lines
42 KiB
TypeScript
1063 lines
42 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|