Compare commits
4
Commits
102cae6692
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac9044f85b | ||
|
|
f542a51776 | ||
|
|
433cc22667 | ||
|
|
75c7b2e6f3 |
+27
-23
@@ -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>
|
||||
|
||||
+441
-13
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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)}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user