fix:前端修改洋洋在信我最后一次

This commit is contained in:
2026-09-15 16:08:44 +08:00
parent 433cc22667
commit f542a51776
5 changed files with 948 additions and 187 deletions
+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)}`;