Files
Mutual_Fund/frontend/lib/api.ts
T

239 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<T>(path: string, form: FormData, method = "POST"): Promise<T> {
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<Blob> {
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;
/** 客服 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<SSESummary> {
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<string, unknown>;
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) ?? "助手返回错误,请稍后重试";
} 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<string, unknown>)
: 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 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;
}