Files
Mutual_Fund/frontend/lib/api.ts
T

199 lines
6.5 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";
}
/**
* 后端存在两套成功码,必须都认:
* - 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;
}