const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/backend"; const TOKEN_KEY = "huaxia_token"; export class ApiError extends Error { status: number; code: number | string | undefined; constructor(message: string, status: number, code?: number | string) { super(message); this.name = "ApiError"; this.status = status; this.code = code; } } function authHeaders(init: RequestInit = {}, json = true): Headers { const token = typeof window === "undefined" ? null : localStorage.getItem(TOKEN_KEY); const headers = new Headers(init.headers); if (json) headers.set("Content-Type", "application/json"); if (token) headers.set("Authorization", `Bearer ${token}`); return headers; } function handleUnauthorized(status: number) { if (status !== 401 || typeof window === "undefined") return; localStorage.removeItem(TOKEN_KEY); if (!window.location.pathname.startsWith("/login")) window.location.href = "/login"; } /** * 从错误响应体里取后端给的原因(如「会话不存在或已过期」)。 * 非 2xx 时后端返回的是 JSON(utils/response.fail),部分场景也会包成 SSE。 */ function extractErrorMessage(body: string): string | undefined { const text = body.trim(); if (!text) return undefined; const jsonText = text.startsWith("data:") ? text.split("\n")[0].slice(5).trim() : text; try { const payload = JSON.parse(jsonText) as { message?: string; error?: string }; return payload?.message ?? payload?.error; } catch { return undefined; } } /** * 后端存在两套成功码,必须都认: * - utils/response.success → code 200(工作台、风控、工单等) * - agent/advisor_agent/protocol.agent_success → code 0(投顾 Agent 系列接口) * 只认 200 会把 Agent 的正常响应误判成业务失败。 */ function isSuccessCode(code: unknown): boolean { return code === undefined || code === null || code === 200 || code === 0; } export async function apiFetch(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, headers: authHeaders(init), cache: "no-store", }); const payload = await response.json().catch(() => null); handleUnauthorized(response.status); if (!response.ok || (payload && !isSuccessCode(payload.code))) { throw new ApiError( payload?.message ?? "请求失败,请稍后重试", response.status, payload?.code ); } return (payload?.data ?? payload) as T; } /** multipart/form-data 提交(知识库文档上传),不能预设 Content-Type,交给浏览器带 boundary。 */ export async function apiFetchForm(path: string, form: FormData, method = "POST"): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { method, headers: authHeaders({}, false), body: form, cache: "no-store", }); const payload = await response.json().catch(() => null); handleUnauthorized(response.status); if (!response.ok || (payload && !isSuccessCode(payload.code))) { throw new ApiError(payload?.message ?? "提交失败,请稍后重试", response.status, payload?.code); } return (payload?.data ?? payload) as T; } /** 下载二进制/CSV 响应(审计台账导出等),返回 Blob 由调用方落盘。 */ export async function apiFetchBlob(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, headers: authHeaders(init, false), cache: "no-store", }); handleUnauthorized(response.status); if (!response.ok) throw new ApiError("导出失败,请稍后重试", response.status); return response.blob(); } export function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } /* ------------------------------------------------------------------ SSE */ /** * 后端 SSE 事件:每行形如 `data: {json}\n\n`,type 取值 * meta / text / done / error(见 common_const SSE_EVENT_TYPE_*)。 */ export interface SSEEvent { type?: string; content?: string; intent?: string; draft_id?: string; query_id?: string; message?: string; code?: number | string; trace_id?: string; [key: string]: unknown; } /** 一次 SSE 请求聚合后的结果。 */ export interface SSESummary { /** 所有 text 事件拼接出的完整回答。 */ text: string; intent?: string; draftId?: string; queryId?: string; traceId?: string; /** error 事件里的提示文案。 */ error?: string; /** error 事件里的后端错误码(如投顾 Agent 的 40001 / 40030 / 50001)。 */ errorCode?: number | string; /** 客服 Agent 命中的知识来源(裸 JSON 响应里的 sources)。 */ sources?: unknown[]; } /** * 读取 /advisor-agent/chat/stream 的完整响应并聚合成一条结果。 * * 后端目前是「SSE 包装的整包输出」:所有分支都先 await 完整结果,再一次性 yield * meta → text → done,text 只有一个且内容是完整回答(见 api/routers/advisor_agent.py)。 * 所以这里按非流式处理——读完整个响应再渲染,避免为了不存在的增量做无用功。 * * 将来后端改成真增量(把 llm_client 的 astream 分片 yield text)时, * 改回 response.body.getReader() 增量解析即可,这里的事件类型契约不用动。 */ export async function readSSE( path: string, body: unknown, signal?: AbortSignal ): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { method: "POST", headers: authHeaders({ headers: { Accept: "text/event-stream" } }), body: JSON.stringify(body), cache: "no-store", signal, }); handleUnauthorized(response.status); if (!response.ok) { const body = await response.text(); throw new ApiError( extractErrorMessage(body) ?? "请求失败,请稍后重试", response.status ); } const summary: SSESummary = { text: "" }; const raw = await response.text(); for (const block of raw.split("\n\n")) { const line = block.split("\n").find((item) => item.startsWith("data:")); if (!line) continue; const data = line.slice(5).trim(); if (!data || data === "[DONE]") continue; let event: SSEEvent; try { event = JSON.parse(data) as SSEEvent; } catch { continue; /* 忽略心跳与非 JSON 块 */ } const record = event as Record; const type = typeof record.type === "string" ? record.type : undefined; if (type === "text") { summary.text += (record.content as string) ?? ""; } else if (type === "meta") { summary.intent = record.intent as string | undefined; summary.draftId = record.draft_id as string | undefined; summary.queryId = record.query_id as string | undefined; summary.traceId = record.trace_id as string | undefined; } else if (type === "done") { if (record.draft_id) summary.draftId = record.draft_id as string; 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 业务对象: * service/customer_agent/chat.py → {answer, sources, intent, rewritten_query, trace_id} * 另兼容 success() 包装的 {code, message, data}(取值时自动下钻一层 data)。 * 旧实现只认 type=text,导致这两个 Agent 的回复恒为空串(聊天框永远回「暂时没有找到合适的回答」)。 */ const node = record.data && typeof record.data === "object" ? (record.data as Record) : record; const answer = node.answer ?? node.content ?? node.summary; if (typeof answer === "string" && answer) { 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; const trace = node.trace_id ?? record.trace_id; if (typeof trace === "string") summary.traceId = trace; if (Array.isArray(node.sources)) summary.sources = node.sources; } } return summary; }