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; [key: string]: unknown; } export interface AgentDraft { draft_id?: string; id?: string; title?: string | null; content?: string | null; intent?: string | null; status?: string | null; customer_id?: number | null; create_time?: string | null; update_time?: string | null; structured_data?: Record | null; [key: string]: unknown; } /** 与后端 nl2sql/contracts.DataQueryResult 对齐,字段只在展示侧挑选。 */ export interface DataQueryResult { query_id?: string; trace_id?: string; customer_id?: number | null; columns?: string[]; rows?: Record[]; row_count?: number; truncated?: boolean; summary?: string | null; answer?: string | null; markdown?: string | null; warnings?: string[]; elapsed_ms?: number; [key: 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[]; analysis_text?: string | null; chart_data?: Record | null; [key: string]: unknown; } export interface FundAnalysisResult extends FundAnalysisItem { /** 命中多只基金时后端返回 items,单只时返回扁平结构。 */ items?: FundAnalysisItem[]; } /** 与后端 agent/advisor_agent/intent/talk_script.build_talk_script 的返回对齐。 */ export interface TalkScriptResult { scene_type?: string; content?: string; [key: string]: unknown; } /* ------------------------------------------------------------------ 常量映射 */ /** 客户端发送的会话上下文选择,与后端 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 = { 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 = { 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 = { 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 = { 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) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== "") search.set(key, String(value)); } const text = search.toString(); 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(`/advisor-agent/session/${encodeURIComponent(sessionId)}/history`), /** * POST /chat/stream —— 客户完全可选。 * customer_id 缺省时后端会自己从问题文本里认客户,认不出就走通用问答,不会报错。 */ chatOnce( body: { query: string; session_id?: string; scope?: ChatScopeMode; customer_id?: number | null; }, signal?: AbortSignal ): Promise { return readSSE("/advisor-agent/chat/stream", body, signal); }, /** * POST /data-query —— 自然语言查客户数据(非流式)。 * 返回里 columns / rows / row_count / truncated / warnings 是真实结果集, * 不要只取 answer 文本,否则表格数据白拿。 * 客户由后端从 query 文本解析,解析不到返回 40001。 */ dataQuery: (body: DataQueryParams) => apiFetch("/advisor-agent/data-query", { method: "POST", body: JSON.stringify(body), }), /** GET /draft/list —— 不传 customer_id 时返回当前投顾名下全部草稿。 */ drafts: (params: DraftListParams = {}) => apiFetch & { 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(`/advisor-agent/draft/${encodeURIComponent(draftId)}`), /** PUT /draft/{draft_id}/save —— 后端会按客户风险等级做适当性校验并可能拒绝。 */ saveDraft: ( draftId: string, body: { title?: string; content?: string; structured_data?: Record } ) => apiFetch(`/advisor-agent/draft/${encodeURIComponent(draftId)}/save`, { method: "PUT", body: JSON.stringify(body), }), /** POST /draft/{draft_id}/operate —— 后端目前仅支持 operation=discard。 */ operateDraft: (draftId: string, operation: "discard") => apiFetch(`/advisor-agent/draft/${encodeURIComponent(draftId)}/operate`, { method: "POST", body: JSON.stringify({ operation }), }), /** * 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。 * 后端要求 query 里含 `字母+数字` 形式的基金代码(形如 F000001), * 纯数字代码抓不到,调用前用 hasFundCode() 先校验。 */ fundAnalysis: (query: string) => apiFetch("/advisor-agent/fund-analysis", { method: "POST", body: JSON.stringify({ query }), }), /** POST /generate-talk-script —— 请求体只有 query。 */ generateTalkScript: (query: string) => apiFetch("/advisor-agent/generate-talk-script", { method: "POST", body: JSON.stringify({ query }), }), }; /* ------------------------------------------------------------------ 轮询 */ 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((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 { 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 & { 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)}`; }