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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 20:58:47 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从错误响应体里取后端给的原因(如「会话不存在或已过期」)。
|
|
|
|
|
|
* 非 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 20:31:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 后端存在两套成功码,必须都认:
|
|
|
|
|
|
* - 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;
|
2026-09-15 16:08:44 +08:00
|
|
|
|
/** error 事件里的后端错误码(如投顾 Agent 的 40001 / 40030 / 50001)。 */
|
|
|
|
|
|
errorCode?: number | string;
|
2026-09-14 20:58:47 +08:00
|
|
|
|
/** 客服 Agent 命中的知识来源(裸 JSON 响应里的 sources)。 */
|
|
|
|
|
|
sources?: unknown[];
|
2026-09-14 20:31:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 读取 /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);
|
2026-09-14 20:58:47 +08:00
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const body = await response.text();
|
|
|
|
|
|
throw new ApiError(
|
|
|
|
|
|
extractErrorMessage(body) ?? "请求失败,请稍后重试",
|
|
|
|
|
|
response.status
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-14 20:31:06 +08:00
|
|
|
|
|
|
|
|
|
|
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 块 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 20:58:47 +08:00
|
|
|
|
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) ?? "助手返回错误,请稍后重试";
|
2026-09-15 16:08:44 +08:00
|
|
|
|
/*
|
|
|
|
|
|
* 投顾 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;
|
2026-09-14 20:58:47 +08:00
|
|
|
|
} 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;
|
2026-09-15 16:08:44 +08:00
|
|
|
|
if (typeof record.code === "number" || typeof record.code === "string") {
|
|
|
|
|
|
summary.errorCode = record.code;
|
|
|
|
|
|
}
|
2026-09-14 20:58:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-09-14 20:31:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return summary;
|
|
|
|
|
|
}
|