fix:前端修改洋洋在信我最后一次
This commit is contained in:
+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)}`;
|
||||
|
||||
Reference in New Issue
Block a user