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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|