feat:前端修改(最后一次)

This commit is contained in:
2026-09-14 20:58:47 +08:00
parent d9c2c5e207
commit 0835335978
6 changed files with 395 additions and 90 deletions
+62 -22
View File
@@ -29,6 +29,22 @@ function handleUnauthorized(status: number) {
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(工作台、风控、工单等)
@@ -126,6 +142,8 @@ export interface SSESummary {
traceId?: string;
/** error 事件里的提示文案。 */
error?: string;
/** 客服 Agent 命中的知识来源(裸 JSON 响应里的 sources)。 */
sources?: unknown[];
}
/**
@@ -152,7 +170,13 @@ export async function readSSE(
});
handleUnauthorized(response.status);
if (!response.ok) throw new ApiError("请求失败,请稍后重试", 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();
@@ -170,29 +194,45 @@ export async function readSSE(
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 ?? "助手返回错误,请稍后重试";
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;
}
/**
* 取 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;
}
+44
View File
@@ -0,0 +1,44 @@
import { apiFetch, readSSE, type SSESummary } from "@/lib/api";
/**
* 客户端两个 Agent 的会话适配层。
*
* 两者都是「打开建会话 → 带 session_id 对话 → 关闭结束会话」的三段式,
* 后端的会话归属校验会拒绝未创建或不属于自己的 session_id(SessionOwnershipError),
* 所以 session_id 必须来自 session/create,不能在前端自己造。
*
* - customer:匿名客服 Agent(/api/agent/customer/*),游客与登录用户都可用,无需 token
* - client :登录客户 Agent(/api/agent/client/*),需 require_customer,
* 能读到本人持仓 / 交易(匿名版没有 customer_id,数据查询会被引导去登录)
*/
export type ChatAgentMode = "customer" | "client";
const PREFIX: Record<ChatAgentMode, string> = {
customer: "/agent/customer",
client: "/agent/client",
};
export interface AgentSession {
session_id: string;
customer_id: number | null;
}
export const chatAgentApi = {
/** POST /session/create —— 建立会话,返回后端签发的 session_id。 */
createSession: (mode: ChatAgentMode) =>
apiFetch<AgentSession>(`${PREFIX[mode]}/session/create`, { method: "POST" }),
/** POST /chat —— 响应是 SSE 包装,但内容为 {answer, sources, intent...}。 */
chat: (
mode: ChatAgentMode,
body: { session_id: string; query: string },
signal?: AbortSignal
): Promise<SSESummary> => readSSE(`${PREFIX[mode]}/chat`, body, signal),
/** POST /session/end —— 关闭会话并清理服务端上下文与限流键。 */
endSession: (mode: ChatAgentMode, sessionId: string) =>
apiFetch<{ session_id: string; archived?: boolean }>(`${PREFIX[mode]}/session/end`, {
method: "POST",
body: JSON.stringify({ session_id: sessionId }),
}),
};