Files
Mutual_Fund/frontend/lib/api.ts
T

199 lines
6.5 KiB
TypeScript
Raw Normal View History

2026-09-14 20:31:06 +08:00
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";
}
/**
* 后端存在两套成功码,必须都认:
* - 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;
}
/**
* 读取 /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) throw new ApiError("请求失败,请稍后重试", 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 块 */
}
if (event.type === "text") {
summary.text += event.content ?? "";
} else if (event.type === "meta") {
summary.intent = event.intent;
summary.draftId = event.draft_id;
summary.queryId = event.query_id;
summary.traceId = event.trace_id;
} else if (event.type === "done") {
if (event.draft_id) summary.draftId = event.draft_id;
if (event.query_id) summary.queryId = event.query_id;
} else if (event.type === "error") {
summary.error = event.message ?? "助手返回错误,请稍后重试";
}
}
return summary;
}
/**
* 取 SSE 响应的正文文本(客服/客户 Agent 聊天用)。
* 旧实现只取第一个 data 行,命中 meta 事件时会拿到空串,这里改为聚合全部 text 事件。
*/
export async function apiStream(path: string, body: unknown): Promise<string> {
const summary = await readSSE(path, body);
return summary.error || summary.text;
}