4 Commits
7 changed files with 1416 additions and 223 deletions
+27 -23
View File
@@ -612,45 +612,49 @@
<div class="links"><span onclick="navTo('screen-home')">首页</span><span onclick="navTo('screen-products')">基金产品</span><span onclick="navTo('screen-account')">用户中心</span><span onclick="navTo('screen-admin')">员工工作台</span></div>
<div class="avatar navlink" onclick="navTo('screen-account')">李</div></div>
<div class="section" style="padding-bottom:0">
<div class="xs faint">欢迎回来</div>
<div class="xs faint">Personal Account</div>
<h1 style="font-size:34px;font-weight:600;color:var(--navy)">用户中心</h1>
<div class="xs muted mt8">139****0041</div>
</div>
<div class="section" style="padding-top:20px">
<div class="grid g4">
<div class="card stat"><div class="label">总资产</div>
<div class="value">¥ 1,284,600</div><div class="sub">较昨日 +0.42%</div></div>
<div class="card stat"><div class="label">持仓收益</div>
<div class="value up">+¥ 86,240</div><div class="sub">收益率 +7.19%</div></div>
<div class="card stat"><div class="label">风险等级</div>
<div class="value">C3</div><div class="sub">稳健型</div></div>
<div class="card stat"><div class="label">待办</div>
<div class="value">2</div><div class="sub">风险测评待更新</div></div>
<div class="row between" style="margin-bottom:16px">
<span class="navlink xs" onclick="navTo('screen-home')">← 返回首页</span>
<button class="btn btn-sm">刷新账户数据</button></div>
<div class="grid g3">
<div class="card stat" style="background:var(--navy);border-color:var(--navy)">
<div class="label" style="color:#bfdbfe">账户余额</div>
<div class="value" style="color:#fff">¥0.00</div>
<div class="sub" style="color:#93c5fd">该账户暂无余额记录</div></div>
<div class="card stat"><div class="label">持仓总金额</div>
<div class="value">¥6,740.45</div><div class="sub">共 1 只基金</div></div>
<div class="card stat"><div class="label">风险测评</div>
<div class="value" style="font-size:19px">待完成测评</div>
<div class="sub">当前后端仅提供提交接口</div></div>
</div>
<div class="grid g2 mt16">
<div class="card"><div class="card-head"><div>
<div class="card-title">我的持仓</div><div class="card-desc">按市值排序</div></div>
<button class="btn btn-sm btn-ghost">刷新</button></div>
<div class="card-title">我的持仓</div>
<div class="card-desc">点击任一行查看该基金详情</div></div></div>
<div class="table-wrap"><table>
<thead><tr><th>基金</th><th>持有份额</th><th>市值</th><th>收益</th></tr></thead>
<thead><tr><th>基金</th><th>持有份额</th><th>市值</th><th>盈亏</th></tr></thead>
<tbody>
<tr><td>华夏成长混合</td><td>12,400.00</td><td>¥ 27,081</td><td class="up">+12.4%</td></tr>
<tr><td>华夏复兴股票</td><td>5,200.00</td><td>¥ 15,818</td><td class="down">-3.1%</td></tr>
<tr><td>华夏稳健债券</td><td>30,000.00</td><td>¥ 39,780</td><td class="up">+2.6%</td></tr>
<tr class="navlink" onclick="navTo('screen-detail')">
<td><div class="b sm">XX沪港深灵活配置</div>
<div class="xs faint">110011 · R4</div></td>
<td>3,910.0000</td><td>¥6,740.45</td><td class="down">-10.11</td></tr>
</tbody></table></div></div>
<div class="card"><div class="card-head"><div>
<div class="card-title">快捷操作</div></div></div>
<div class="card-body">
<div class="row wrap">
<button class="btn btn-primary">申购</button><button class="btn">赎回</button>
<button class="btn">转换</button><button class="btn">重新测评</button></div>
<div class="mt16 b sm">消息通知</div>
<div class="xs muted mt8">· 您的风险测评将于 30 天后过期</div>
<div class="xs muted">· 华夏成长混合 09-13 净值已更新</div>
<button class="btn btn-primary">申购基金</button><button class="btn">赎回基金</button></div>
<div class="mt16 b sm">交易记录</div>
<div class="xs muted mt8">· 09-12 申购 华夏稳健债券 ¥ 10,000</div>
<div class="xs muted">· 09-05 赎回 华夏复兴股票 1,000 份</div>
<div class="xs muted mt8">当前后端暂未提供交易记录查询接口,页面不会展示虚构数据。</div>
<div class="mt16 b sm">消息通知</div>
<div class="xs muted mt8">当前后端暂未提供用户消息查询接口。</div>
</div></div>
</div>
</div>
File diff suppressed because one or more lines are too long
+47 -6
View File
@@ -22,7 +22,16 @@ import {
Select,
} from "@/components/admin/ui";
import { adminApi, errMessage } from "@/lib/admin-api";
import { buildKnowledgeForm, knowledgeApi, type KnowledgeDocument, type UploadPreview } from "@/lib/knowledge-api";
import {
buildKnowledgeForm,
collectionLabel,
DEFAULT_KNOWLEDGE_COLLECTION,
KNOWLEDGE_COLLECTIONS,
knowledgeApi,
knowledgeErrorHint,
type KnowledgeDocument,
type UploadPreview,
} from "@/lib/knowledge-api";
import { isAdmin } from "@/lib/roles";
/** 切分策略,与后端 KnowledgeUploadService 支持的策略保持一致。 */
@@ -38,6 +47,7 @@ export default function KnowledgePage() {
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [collection, setCollection] = useState<string>(DEFAULT_KNOWLEDGE_COLLECTION);
const [strategy, setStrategy] = useState("default");
const [chunkSize, setChunkSize] = useState("");
const [chunkOverlap, setChunkOverlap] = useState("");
@@ -96,6 +106,7 @@ export default function KnowledgePage() {
try {
const result = await knowledgeApi.preview(
buildKnowledgeForm(file, {
collection_name: collection,
strategy,
chunk_size: chunkSize ? Number(chunkSize) : null,
chunk_overlap: chunkOverlap ? Number(chunkOverlap) : null,
@@ -104,7 +115,7 @@ export default function KnowledgePage() {
setPreview(result);
if (!title) setTitle(file.name);
} catch (err) {
setError(errMessage(err, "预览失败,请检查文件格式"));
setError(knowledgeErrorHint(errMessage(err, "预览失败,请检查文件格式")));
} finally {
setUploading(false);
}
@@ -112,6 +123,11 @@ export default function KnowledgePage() {
const runConfirm = async () => {
if (!file || uploading) return;
// 集合是后端白名单校验的必填项,缺了会直接 400「知识库集合不在允许范围内」
if (!collection) {
setError("请先选择知识库集合");
return;
}
setUploading(true);
setError(null);
setNotice(null);
@@ -120,16 +136,17 @@ export default function KnowledgePage() {
buildKnowledgeForm(file, {
title: title || file.name,
doc_id: preview?.doc_id ? String(preview.doc_id) : undefined,
collection_name: collection,
strategy,
chunk_size: chunkSize ? Number(chunkSize) : null,
chunk_overlap: chunkOverlap ? Number(chunkOverlap) : null,
})
);
setNotice(`《${title || file.name}》已入库`);
setNotice(`《${title || file.name}》已入库到「${collectionLabel(collection)}」`);
resetUpload();
await load();
} catch (err) {
setError(errMessage(err, "入库失败,请稍后重试"));
setError(knowledgeErrorHint(errMessage(err, "入库失败,请稍后重试")));
} finally {
setUploading(false);
}
@@ -211,7 +228,7 @@ export default function KnowledgePage() {
</span>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<label className="space-y-1.5">
<span className="text-xs text-slate-500">标题</span>
<TextInput
@@ -220,6 +237,25 @@ export default function KnowledgePage() {
placeholder="用于检索展示的标题"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs text-slate-500">
知识库集合 <span className="text-rose-500">*</span>
</span>
<Select
value={collection}
onChange={(event) => setCollection(event.target.value)}
>
{KNOWLEDGE_COLLECTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}({item.value})
</option>
))}
</Select>
<span className="block text-[11px] leading-4 text-slate-400">
{KNOWLEDGE_COLLECTIONS.find((item) => item.value === collection)
?.description ?? "决定文档写入哪个向量集合,入库后不可修改"}
</span>
</label>
<label className="space-y-1.5">
<span className="text-xs text-slate-500">切分策略</span>
<Select
@@ -284,6 +320,7 @@ export default function KnowledgePage() {
{preview && (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<Badge tone="info">{collectionLabel(collection)}</Badge>
<Badge tone="info">{preview.strategy || strategy}</Badge>
{preview.chunk_size != null && <span>分片 {preview.chunk_size}</span>}
{preview.chunk_overlap != null && <span>重叠 {preview.chunk_overlap}</span>}
@@ -338,7 +375,11 @@ export default function KnowledgePage() {
{item.title ?? item.filename ?? docId}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-400">
{item.collection_name && <span>{item.collection_name}</span>}
{item.collection_name && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500">
{collectionLabel(item.collection_name)}
</span>
)}
{item.chunk_count != null && <span>{item.chunk_count} 段</span>}
{item.create_time && <span>{item.create_time}</span>}
<span className="font-mono">{docId}</span>
+559 -148
View File
@@ -4,6 +4,8 @@ import {
AlertCircle,
BarChart3,
Bot,
FileText,
Info,
Loader2,
MessageSquareQuote,
Minus,
@@ -20,38 +22,146 @@ import {
Button,
EmptyState,
Modal,
Select,
cn,
formatTime,
statusTone,
useToast,
} from "@/components/admin/ui";
import { adminApi, errMessage } from "@/lib/admin-api";
import { assistantApi, newSessionId, type AgentDraft } from "@/lib/assistant-api";
import type { Customer } from "@/lib/admin-api";
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: "问题里需带上基金代码,如 000001" },
{ key: "talk-script", label: "生成话术", icon: MessageSquareQuote, hint: "按场景生成合规话术草稿" },
{ key: "rebalance", label: "调仓再平衡", icon: RefreshCw, hint: "异步生成调仓建议草稿" },
{
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 工具、管理草稿;
* 「结束会话」会中止生成并重置 session,历史不再延续到下一次对话。
* 悬浮球打开抽屉后可直接对话、调用 Agent 工具、管理草稿。
*
* 客户不是必填项:/chat/stream 的 customer_id 可选、另外四个工具接口没有该字段,
* 后端统一从问题文本里认客户(_resolve_customer_from_query),所以前端不做客户选择,
* 只在用户没写明客户导致 40001 时给出可操作的提示。
*
* 另外 /rebalance/run 是异步受理,草稿要靠轮询 /draft/list 才拿得到。
*/
export function AssistantLauncher() {
const toast = useToast();
@@ -60,93 +170,65 @@ export function AssistantLauncher() {
/* 会话在「打开抽屉」时才建立(见 openAssistant),关闭时丢弃,不在挂载时就占用上下文 */
const [sessionId, setSessionId] = useState<string | null>(null);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerId, setCustomerId] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
/** streaming = SSE 对话在途;pending = 工具调用 / 草稿轮询在途。 */
const [streaming, setStreaming] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
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 (!streaming) {
if (!busy) {
setElapsed(0);
return;
}
const timer = setInterval(() => setElapsed((value) => value + 1), 1000);
return () => clearInterval(timer);
}, [streaming]);
}, [busy]);
const sessionTag = useMemo(() => (sessionId ? sessionId.slice(-6) : "--------"), [sessionId]);
/* 客户列表只用于会话上下文选择,失败不阻断助手 */
useEffect(() => {
void (async () => {
try {
const page = await adminApi.customers({ page: 1, page_size: 100 });
setCustomers(page.items ?? []);
} catch {
/* 静默 */
}
})();
}, []);
/*
* 会话握手:打开抽屉(sessionId 就绪)后拉一次该会话的记录。
* 投顾 Agent 没有独立的 session/create,后端按 (advisor_id, session_id) 惰性建立上下文,
* 所以这个请求就是「打开助手时发出的会话请求」;关闭抽屉会把 sessionId 置空、丢弃上下文。
* 投顾 Agent 没有独立的 session/create:后端按 (advisor_id, session_id) 惰性建立上下文,
* session_id 完全由前端生成,服务端不可能存在它的历史记录,
* 所以打开抽屉时不再多发一次必然为空的 /session/{id}/history(省一次鉴权往返)。
* 一旦后端补上会话持久化,再在这里补一次 hydrate 即可。
*/
useEffect(() => {
if (!sessionId) {
setMessages([]);
setHistoryError(null);
setHistoryLoading(false);
return;
}
let alive = true;
void (async () => {
setHistoryLoading(true);
setHistoryError(null);
try {
const history = await assistantApi.sessionHistory(sessionId);
if (!alive) return;
setMessages(
(history ?? [])
.filter((item) => item?.content)
.map((item, index) => ({
id: `h-${index}`,
role: item.role === "user" ? "user" : "assistant",
content: String(item.content ?? ""),
}))
);
} catch (error) {
if (!alive) return;
setHistoryError(errMessage(error, "会话历史加载失败"));
} finally {
if (alive) setHistoryLoading(false);
}
})();
return () => {
alive = false;
};
}, [sessionId]);
const loadDrafts = useCallback(async () => {
setDraftsLoading(true);
try {
/* 不带 customer_id:后端默认返回当前投顾名下的全部草稿 */
const page = await assistantApi.drafts({ page: 1, page_size: 20 });
setDrafts(page.items ?? []);
const items = page.items ?? [];
draftsRef.current = items;
setDrafts(items);
} catch {
/* 草稿列表加载失败不打断对话 */
} finally {
@@ -164,18 +246,43 @@ export function AssistantLauncher() {
useEffect(() => () => abortRef.current?.abort(), []);
const pushPair = useCallback((query: string, answer: string, intent?: string) => {
setMessages((prev) => [
...prev,
{ id: `u-${Date.now()}`, role: "user", content: query },
{ id: `a-${Date.now()}`, role: "assistant", content: answer, intent },
]);
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 || streaming) return;
if (!query || busy) return;
const replyId = `a-${Date.now()}`;
setMessages((prev) => [
@@ -190,17 +297,20 @@ export function AssistantLauncher() {
abortRef.current = controller;
try {
/* 客户可选:不传 customer_id,后端自己从问题文本里认客户,认不出就走通用问答 */
const result = await assistantApi.chatOnce(
{
query,
session_id: sessionId ?? undefined,
scope: customerId ? "customer" : "advisor",
customer_id: customerId,
},
{ 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
@@ -210,6 +320,10 @@ export function AssistantLauncher() {
intent: result.intent,
draftId: result.draftId,
error: !!result.error,
traceId: result.traceId,
hint: hint ?? undefined,
retryTool,
retryQuery: retryTool ? query : undefined,
}
: item
)
@@ -234,59 +348,176 @@ export function AssistantLauncher() {
abortRef.current = null;
}
},
[customerId, loadDrafts, sessionId, streaming]
[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) => {
const query = input.trim();
async (tool: ToolKey, override?: string) => {
const query = (override ?? input).trim();
if (!query) {
toast("请先在输入框描述你的问题", "error");
return;
}
if (tool === "rebalance" && !customerId) {
toast("调仓需要指定客户,请先在上方选择客户", "error");
if (tool === "fund-analysis" && !hasFundCode(query)) {
toast("基金分析需要基金代码,请写完整代码(形如 F000001)", "error");
return;
}
setStreaming(true);
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 });
pushPair(query, result.answer ?? result.summary ?? "查询完成,无文本摘要。", "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);
pushPair(
query,
result.analysis_text ??
(Array.isArray(result.items) ? JSON.stringify(result.items, null, 2) : "未返回结果"),
"fund_analysis"
);
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);
pushPair(query, result.script ?? result.content ?? "未生成话术。", "dialogue-script");
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);
toast(
result.accepted ? "调仓任务已受理,草稿稍后生成" : "调仓任务未受理",
result.accepted ? "success" : "error"
);
void loadDrafts();
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);
}
}
setInput("");
} catch (error) {
toast(errMessage(error, "工具调用失败"), "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 {
setStreaming(false);
setPending(false);
abortRef.current = null;
}
},
[customerId, input, loadDrafts, pushPair, sessionId, toast]
[input, loadDrafts, pushExchange, sessionId, setAssistantMessage, toast, upsertReply]
);
/* ---------------------------------------------------------------- 会话 */
/**
* 打开抽屉 = 建立会话。
* 投顾 Agent 后端只有 chat/stream 与 session/{id}/history,没有独立的 session/create,
* 所以这里生成会话键,由上面的 useEffect 调 history 完成握手(后端按需建立上下文)。
* 打开抽屉 = 建立会话。投顾侧没有 session/create,session_id 由前端生成,
* 后端按 (advisor_id, session_id) 惰性建立上下文。
*/
const openAssistant = useCallback(() => {
setOpen(true);
@@ -295,7 +526,7 @@ export function AssistantLauncher() {
/**
* 关闭抽屉 = 结束会话:中止生成、清空消息并丢弃上下文。
* 投顾侧后端未提供 session/end,因此结束只能在前端完成(下次打开是新的 session_id)。
* 后端未提供 session/end,结束只能在前端完成(下次打开是新的 session_id)。
*/
const closeAssistant = useCallback(() => {
abortRef.current?.abort();
@@ -303,19 +534,23 @@ export function AssistantLauncher() {
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 {
@@ -358,6 +593,10 @@ export function AssistantLauncher() {
[loadDrafts, toast]
);
/* ---------------------------------------------------------------- 渲染 */
const tableHasNext = !!tableModal && tableModal.rows.length >= tableModal.pageSize;
return (
<>
{!open && (
@@ -391,10 +630,10 @@ export function AssistantLauncher() {
<p className="text-sm font-semibold text-slate-900">投顾助手</p>
<p className="flex items-center gap-1.5 text-xs text-slate-400">
会话 #{sessionTag}
{streaming && (
{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>
@@ -412,22 +651,7 @@ export function AssistantLauncher() {
</button>
</header>
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
<Select
value={customerId ? String(customerId) : ""}
onChange={(event) =>
setCustomerId(event.target.value ? Number(event.target.value) : null)
}
className="flex-1"
>
<option value="">未指定客户(通用问答)</option>
{customers.map((customer) => (
<option key={customer.customer_id} value={customer.customer_id}>
#{customer.customer_id} {customer.real_name ?? "未填写姓名"}
</option>
))}
</Select>
<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
@@ -447,21 +671,14 @@ export function AssistantLauncher() {
{tab === "chat" ? (
<>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto px-5 py-4">
{historyLoading && messages.length === 0 && (
<p className="py-8 text-center text-sm text-slate-400">正在建立会话...</p>
)}
{historyError && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">{historyError}</p>
)}
{!historyLoading && messages.length === 0 && (
{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>
)}
@@ -487,7 +704,7 @@ export function AssistantLauncher() {
</div>
<div
className={cn(
"max-w-[80%] rounded-xl px-3.5 py-2.5 text-sm leading-6",
"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
@@ -503,8 +720,96 @@ export function AssistantLauncher() {
助手正在思考{elapsed > 0 ? ` ${elapsed}s` : ""}
</p>
)}
{message.intent && (
<p className="mt-1.5 text-xs opacity-70">意图:{message.intent}</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>
@@ -517,7 +822,7 @@ export function AssistantLauncher() {
<button
key={tool.key}
onClick={() => void runTool(tool.key)}
disabled={streaming}
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"
>
@@ -530,6 +835,7 @@ export function AssistantLauncher() {
<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) {
@@ -538,15 +844,15 @@ export function AssistantLauncher() {
}
}}
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"
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"
/>
{streaming ? (
{busy ? (
<Button
variant="danger"
onClick={() => abortRef.current?.abort()}
aria-label="取消本次提问"
title="取消本次提问"
aria-label="取消本次请求"
title="取消本次请求"
>
<Square className="h-4 w-4" />
</Button>
@@ -561,7 +867,15 @@ export function AssistantLauncher() {
) : (
<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">助手生成的建议草稿</p>
<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")} />
刷新
@@ -573,7 +887,7 @@ export function AssistantLauncher() {
) : (
<ul className="mt-2 divide-y divide-slate-100">
{drafts.map((draft) => {
const id = draft.draft_id ?? draft.id ?? "";
const id = draftKey(draft);
return (
<li key={id} className="px-5 py-3">
<div className="flex items-start justify-between gap-3">
@@ -585,9 +899,16 @@ export function AssistantLauncher() {
{draft.content || "(无正文)"}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{draft.intent && <Badge tone="info">{draft.intent}</Badge>}
{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)}>{draft.status}</Badge>
<Badge tone={statusTone(draft.status)}>
{draftStatusLabel(draft.status)}
</Badge>
)}
<span className="text-xs text-slate-400">
{formatTime(draft.update_time ?? draft.create_time)}
@@ -619,6 +940,95 @@ export function AssistantLauncher() {
</>
)}
{/* 数据表明细:抽屉太窄,全量结果和翻页放在独立弹层 */}
{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="编辑草稿"
@@ -634,6 +1044,7 @@ export function AssistantLauncher() {
>
<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}
+13
View File
@@ -142,6 +142,8 @@ export interface SSESummary {
traceId?: string;
/** error 事件里的提示文案。 */
error?: string;
/** error 事件里的后端错误码(如投顾 Agent 的 40001 / 40030 / 50001)。 */
errorCode?: number | string;
/** 客服 Agent 命中的知识来源(裸 JSON 响应里的 sources)。 */
sources?: unknown[];
}
@@ -209,6 +211,14 @@ export async function readSSE(
if (record.query_id) summary.queryId = record.query_id as string;
} else if (type === "error") {
summary.error = (record.message as string) ?? "助手返回错误,请稍后重试";
/*
* 投顾 Agent 的失败也走 SSE(agent_failure),不是 HTTP 4xx,
* 错误码和 trace_id 只在事件体里,取出来才能给「下一步怎么做」的提示和排查线索。
*/
if (typeof record.code === "number" || typeof record.code === "string") {
summary.errorCode = record.code;
}
if (typeof record.trace_id === "string") summary.traceId = record.trace_id;
} else if (!type) {
/*
* 无 type 的响应——客服 Agent / 客户 Agent 不用事件包装,直接 dump 业务对象:
@@ -225,6 +235,9 @@ export async function readSSE(
summary.text += answer;
} else if (typeof record.message === "string" && record.message) {
summary.error = record.message;
if (typeof record.code === "number" || typeof record.code === "string") {
summary.errorCode = record.code;
}
}
if (typeof node.intent === "string") summary.intent = node.intent;
if (typeof node.query_id === "string") summary.queryId = node.query_id;
+264 -33
View File
@@ -1,11 +1,23 @@
import { apiFetch, readSSE, type SSESummary } from "@/lib/api";
import { ApiError, apiFetch, readSSE, type SSESummary } from "@/lib/api";
import type { Page } from "@/lib/admin-api";
/**
* 投顾 Agent(/api/advisor-agent/*)适配层。
*
* 该系列接口的成功码是 0 而不是 200(agent_success),已在 lib/api.ts 统一兼容。
*
* 关于客户上下文:客户不是必填项,前端不做单独的客户选择。
* - POST /chat/stream 的 customer_id 是可选的(AgentChatReq 默认 None),
* 不传时后端会自己从问题文本里认客户,认不出就按「未指定客户」走通用问答;
* - /data-query、/fund-analysis、/generate-talk-script、/rebalance/run
* 请求体里没有 customer_id 字段,后端统一用 _resolve_customer_from_query
* 从 query 文本里解析「客户 #12」或已授权客户的姓名(api/routers/advisor_agent.py)。
* 所以只要用户在问题里写明客户,工具类接口就能正常解析;写不清楚时后端返回 40001,
* 由 advisorErrorHint() 给出可操作的中文提示。
*/
/* ------------------------------------------------------------------ 契约 */
export interface AgentMessage {
role?: string;
content?: string;
@@ -26,38 +38,147 @@ export interface AgentDraft {
[key: string]: unknown;
}
/** 与后端 nl2sql/contracts.DataQueryResult 对齐,字段只在展示侧挑选。 */
export interface DataQueryResult {
query_id?: string;
answer?: string;
summary?: string;
trace_id?: string;
customer_id?: number | null;
columns?: string[];
rows?: Record<string, unknown>[];
total?: number;
page?: number;
page_size?: number;
row_count?: number;
truncated?: boolean;
summary?: string | null;
answer?: string | null;
markdown?: string | null;
warnings?: string[];
elapsed_ms?: number;
[key: string]: unknown;
}
export interface FundAnalysisResult {
analysis_text?: string;
items?: Record<string, unknown>[];
/** 与后端 agent/advisor_agent/intent/fund_analysis.build_fund_analysis 的返回对齐。 */
export interface FundAnalysisItem {
fund_code?: string | null;
fund_name?: string | null;
risk_level?: string | null;
metrics?: Record<string, number | null>[];
analysis_text?: string | null;
chart_data?: Record<string, unknown> | null;
[key: string]: unknown;
}
export interface FundAnalysisResult extends FundAnalysisItem {
/** 命中多只基金时后端返回 items,单只时返回扁平结构。 */
items?: FundAnalysisItem[];
}
/** 与后端 agent/advisor_agent/intent/talk_script.build_talk_script 的返回对齐。 */
export interface TalkScriptResult {
script?: string;
scene_type?: string;
content?: string;
scene?: string;
[key: string]: unknown;
}
/** 会话上下文:scope=customer 时必须带 customer_id,否则后端会尝试从问题文本里猜客户。 */
export interface ChatScope {
sessionId?: string;
customerId?: number | null;
scope?: "customer" | "advisor";
/* ------------------------------------------------------------------ 常量映射 */
/** 客户端发送的会话上下文选择,与后端 AdvisorChatReq.scope 对齐。 */
export type ChatScopeMode = "customer" | "advisor";
/** 后端 common_const 的 AGENT_INTENT_* / 兜底值。 */
export type AdvisorIntent =
| "recommend"
| "rebalance"
| "fund_analysis"
| "dialogue-script"
| "data_query"
| "casual_chat"
| "general_question"
| string;
const INTENT_LABELS: Record<string, string> = {
recommend: "产品推荐",
rebalance: "调仓再平衡",
fund_analysis: "基金分析",
"dialogue-script": "沟通话术",
data_query: "客户数据查询",
casual_chat: "闲聊",
general_question: "通用问答",
};
/** 意图英文值 → 中文标签;未知值原样返回,便于排查后端新增意图。 */
export function intentLabel(intent?: string | null): string | null {
if (!intent) return null;
return INTENT_LABELS[intent] ?? intent;
}
/** 话术场景(后端 TALK_SCENE_*)。 */
const SCENE_LABELS: Record<string, string> = {
market_fluctuation: "市场波动安抚",
customer_complaint: "客户投诉",
risk_block_order: "订单被风控拦截",
portfolio_divergence: "组合偏离基准",
};
export function sceneLabel(scene?: string | null): string | null {
if (!scene) return null;
return SCENE_LABELS[scene] ?? scene;
}
/** 草稿状态(后端 DRAFT_STATUS_*)。 */
const DRAFT_STATUS_LABELS: Record<string, string> = {
draft: "草稿",
discarded: "已废弃",
sent: "已发送",
};
export function draftStatusLabel(status?: string | null): string | null {
if (!status) return null;
return DRAFT_STATUS_LABELS[status] ?? status;
}
/* ------------------------------------------------------------------ 文本规则 */
/**
* 后端 /fund-analysis 用 `[A-Za-z]{1,6}\d{3,8}` 抓基金代码,
* 且按 product_code 精确匹配(库里形如 F000001)。
* 纯数字的 000001 抓不到,前端要提前给出可操作的提示,而不是等后端报「请补充基金代码」。
*/
export const FUND_CODE_RE = /[A-Za-z]{1,6}\d{3,8}/g;
export function extractFundCodes(query: string): string[] {
return query.toUpperCase().match(FUND_CODE_RE) ?? [];
}
export function hasFundCode(query: string): boolean {
return extractFundCodes(query).length > 0;
}
/* ------------------------------------------------------------------ 错误文案 */
/** 后端错误码 → 可执行的中文提示(common_const.py)。 */
const CODE_HINTS: Record<number, string> = {
40001: "请在问题里带上客户编号或姓名,例如「客户 #12 最近一个月的持仓明细」。",
40020: "方案适当性校验未通过:包含超出客户风险等级的产品。",
40030: "该客户尚未签约,无法生成调仓草稿。",
40401: "草稿不存在或已废弃,刷新草稿列表看看。",
50001: "AI 服务调用异常,可以稍后重试,或换一种问法。",
};
/**
* 把后端 message 转成「原因 + 下一步」。
* 后端部分能力尚未装配时返回未初始化错误(ERR_CODE_LLM_ERROR),单独给一句更准确的指引。
*/
export function advisorErrorHint(message?: string | null, code?: number | string): string | null {
if (!message) return null;
if (message.includes("核心能力尚未初始化")) {
return "这句话触发的意图后端暂未装配。调仓 / 再平衡请用下方「调仓再平衡」工具;数据查询请用「查客户数据」。";
}
const numeric = typeof code === "string" ? Number(code) : code;
if (typeof numeric === "number" && CODE_HINTS[numeric]) return CODE_HINTS[numeric];
return null;
}
/* ------------------------------------------------------------------ 请求 */
function qs(params: Record<string, string | number | undefined | null>) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@@ -67,17 +188,38 @@ function qs(params: Record<string, string | number | undefined | null>) {
return text ? `?${text}` : "";
}
export interface DataQueryParams {
query: string;
session_id?: string;
/** 后端限制:max_rows ≤ 10000,page ≥ 1,page_size ∈ [1, 10000]。 */
max_rows?: number;
page?: number;
page_size?: number;
sort_by?: string;
sort_order?: "asc" | "desc";
}
export interface DraftListParams {
customer_id?: number;
status?: "draft" | "discarded";
page?: number;
page_size?: number;
}
export const assistantApi = {
/** GET /session/{session_id}/history —— 读取本人短期 Agent 会话记录。 */
sessionHistory: (sessionId: string) =>
apiFetch<AgentMessage[]>(`/advisor-agent/session/${encodeURIComponent(sessionId)}/history`),
/** POST /chat/stream —— 读取 SSE,并支持调用方实时消费事件。 */
/**
* POST /chat/stream —— 客户完全可选。
* customer_id 缺省时后端会自己从问题文本里认客户,认不出就走通用问答,不会报错。
*/
chatOnce(
body: {
query: string;
session_id?: string;
scope?: "customer" | "advisor";
scope?: ChatScopeMode;
customer_id?: number | null;
},
signal?: AbortSignal
@@ -85,28 +227,34 @@ export const assistantApi = {
return readSSE("/advisor-agent/chat/stream", body, signal);
},
/** POST /data-query —— 自然语言查客户数据(非流式)。 */
dataQuery: (body: {
query: string;
session_id?: string;
max_rows?: number;
page?: number;
page_size?: number;
}) =>
/**
* POST /data-query —— 自然语言查客户数据(非流式)。
* 返回里 columns / rows / row_count / truncated / warnings 是真实结果集,
* 不要只取 answer 文本,否则表格数据白拿。
* 客户由后端从 query 文本解析,解析不到返回 40001。
*/
dataQuery: (body: DataQueryParams) =>
apiFetch<DataQueryResult>("/advisor-agent/data-query", {
method: "POST",
body: JSON.stringify(body),
}),
/** GET /draft/list */
drafts: (params: { customer_id?: number; status?: string; page?: number; page_size?: number } = {}) =>
apiFetch<Page<AgentDraft> & { items?: AgentDraft[] }>(`/advisor-agent/draft/list${qs(params)}`),
/** GET /draft/list —— 不传 customer_id 时返回当前投顾名下全部草稿。 */
drafts: (params: DraftListParams = {}) =>
apiFetch<Page<AgentDraft> & { items?: AgentDraft[] }>(
`/advisor-agent/draft/list${qs({
customer_id: params.customer_id,
status: params.status,
page: params.page,
page_size: params.page_size,
})}`
),
/** GET /draft/{draft_id} */
draft: (draftId: string) =>
apiFetch<AgentDraft>(`/advisor-agent/draft/${encodeURIComponent(draftId)}`),
/** PUT /draft/{draft_id}/save */
/** PUT /draft/{draft_id}/save —— 后端会按客户风险等级做适当性校验并可能拒绝。 */
saveDraft: (
draftId: string,
body: { title?: string; content?: string; structured_data?: Record<string, unknown> }
@@ -123,21 +271,29 @@ export const assistantApi = {
body: JSON.stringify({ operation }),
}),
/** POST /rebalance/run —— 异步受理,返回 accepted/queued,草稿稍后生成。 */
/**
* POST /rebalance/run —— 异步受理:立即返回 {accepted, status:"queued"},
* 草稿由后台任务生成,需要通过 waitForNewDraft() 轮询草稿列表才能拿到。
* 请求体只有 query;客户未签约会返回 40030。
*/
runRebalance: (query: string) =>
apiFetch<{ accepted?: boolean; status?: string }>("/advisor-agent/rebalance/run", {
method: "POST",
body: JSON.stringify({ query }),
}),
/** POST /fund-analysis —— query 里需包含基金代码,如「分析 000001」。 */
/**
* POST /fund-analysis —— 请求体只有 query。
* 后端要求 query 里含 `字母+数字` 形式的基金代码(形如 F000001),
* 纯数字代码抓不到,调用前用 hasFundCode() 先校验。
*/
fundAnalysis: (query: string) =>
apiFetch<FundAnalysisResult>("/advisor-agent/fund-analysis", {
method: "POST",
body: JSON.stringify({ query }),
}),
/** POST /generate-talk-script */
/** POST /generate-talk-script —— 请求体只有 query。 */
generateTalkScript: (query: string) =>
apiFetch<TalkScriptResult>("/advisor-agent/generate-talk-script", {
method: "POST",
@@ -145,6 +301,81 @@ export const assistantApi = {
}),
};
/* ------------------------------------------------------------------ 轮询 */
export interface WaitForDraftOptions {
/** 轮询开始前已有的草稿 ID,用来判断「新」。 */
excludeDraftIds?: readonly string[];
/** 草稿出现在哪个意图下,用于收紧匹配(可选)。 */
intent?: string;
timeoutMs?: number;
intervalMs?: number;
signal?: AbortSignal;
}
function draftIdOf(draft: AgentDraft): string {
return draft.draft_id ?? draft.id ?? "";
}
function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}
if (signal?.aborted) return onAbort();
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* 轮询草稿列表,等待 /rebalance/run 的后台任务落库。
*
* 后端是「先受理、后生成」,前端如果只弹一句「稍后生成」就结束,
* 用户实际上看不到结果,只能手动点刷新。这里最多轮询 timeoutMs,
* 命中新草稿就返回;超时返回 null,由调用方提示「可手动刷新」。
*/
export async function waitForNewDraft(options: WaitForDraftOptions = {}): Promise<AgentDraft | null> {
const {
excludeDraftIds = [],
intent,
timeoutMs = 30_000,
intervalMs = 3_000,
signal,
} = options;
const known = new Set(excludeDraftIds);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(intervalMs, signal);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
let page: Page<AgentDraft> & { items?: AgentDraft[] };
try {
page = await assistantApi.drafts({ page: 1, page_size: 20 });
} catch (error) {
/* 单次失败不放弃,继续轮询到超时;401 直接抛出交给上层跳登录 */
if (error instanceof ApiError && error.status === 401) throw error;
continue;
}
const fresh = (page.items ?? []).find((draft) => {
const id = draftIdOf(draft);
if (!id || known.has(id)) return false;
if (intent && draft.intent && draft.intent !== intent) return false;
return true;
});
if (fresh) return fresh;
}
return null;
}
/** 生成本地会话 ID(后端 session_id 为空时自动创建,这里只是前端约定的稳定键)。 */
export function newSessionId(): string {
return `advisor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+65
View File
@@ -5,6 +5,71 @@ import { apiFetch, apiFetchForm } from "@/lib/api";
* 权限:后端 require_knowledge_operator —— 仅管理员或运营角色,前端不做二次判断。
*/
/**
* 知识库集合白名单,必须与后端 `rag/milvus_collections.py::KNOWLEDGE_COLLECTIONS` 完全一致。
* 后端 `KnowledgeUploadService.confirm` 会校验 `collection_name`,不在白名单内直接
* 抛 400「知识库集合不在允许范围内」——所以 confirm 必须带上这个字段,不能为空。
*/
export const KNOWLEDGE_COLLECTIONS = [
{
value: "fin_fund_doc",
label: "基金文档",
description: "基金产品说明、公司业务资料",
},
{
value: "fin_faq",
label: "常见问答",
description: "FAQ 问答对,客服与投顾高频问题",
},
{
value: "fin_policy",
label: "政策法规",
description: "监管政策、合规文件",
},
] as const;
/** 默认入库到基金文档集合(也是检索权重最高的一类业务资料)。 */
export const DEFAULT_KNOWLEDGE_COLLECTION = "fin_fund_doc";
/** 集合英文名 → 中文标签;未知集合原样返回,便于发现后端新增了集合。 */
export function collectionLabel(value?: string | null): string {
if (!value) return "";
return KNOWLEDGE_COLLECTIONS.find((item) => item.value === value)?.label ?? value;
}
/** 校验集合是否在白名单内,供前端提交前前置拦截。 */
export function isValidCollection(value?: string | null): boolean {
return !!value && KNOWLEDGE_COLLECTIONS.some((item) => item.value === value);
}
/**
* 把后端返回的原始报错翻译成可执行的下一步提示。
* 后端 `api/chat/knowledge.py` 统一把 UploadValidationError 转成 400 + 原文,
* 未捕获的异常则被全局处理器兜成 500「服务内部错误」,这里对两者都给出方向。
*/
export function knowledgeErrorHint(message: string): string {
const text = message ?? "";
if (text.includes("知识库集合不在允许范围内")) {
return "请选择入库的知识库集合后再提交";
}
if (text.includes("Unsupported document type")) {
return "文件类型不支持,请上传 PDF / Word / Markdown / TXT 等文本类文档";
}
if (text.includes("doc_id已存在")) {
return "该文档已入库,请先删除旧文档或修改 doc_id";
}
if (text.includes("Embedding维度必须为")) {
return "向量维度与集合不一致,需按当前 embedding 模型重建集合";
}
if (text.includes("文档切片为空")) {
return "文档没切出任何内容,请确认文件不是空文档或纯图片扫描件";
}
if (text.includes("服务内部错误")) {
return "后端依赖异常(通常是向量库 Milvus 未启动或 embedding 服务不可用),请检查基础设施后重试";
}
return text;
}
export interface KnowledgeDocument {
doc_id?: string;
id?: string;