refactor(AI): 四份 SSE 请求代码合成 streamAI + useAIStream
学情分析、班级分析、班级 PK、AI 提示四处原来各有一份几乎一样的 fetch + SSE 解析, 错误处理各漏各的: - 裸 fetch 绕过了 axios 拦截器,未登录时只显示「AI 分析生成失败」而不弹登录框。 拦截器里的全站错误处理抽成 handleGlobalApiError,两条路共用 - 班级分析、班级 PK 离开页面不中止流。useAIStream 在作用域销毁时自动 abort - AI 提示换了一次提交不掐上一条,旧提示会写进新提交的面板 utils/stream.ts 的 streamAI 负责请求 + 解析 + 错误翻译;组件用 useAIStream (waiting = 第一个字还没到,running = 流没结束);学情分析在 store 里直接用 streamAI。 浏览器实跑核过:学情分析逐字流出、provider 500 时显示后端的固定中文文案、 未登录调用弹出登录框。班级 PK / 班级分析卸载即中止这条没有在浏览器里点过 (本机库里没有班级数据),逻辑在 useAIStream 的 onScopeDispose。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
09030240b8
commit
c43da01ff2
7 files changed
+202
-205
No files matched your search
+24
-16
@@ -27,6 +27,29 @@ interface ApiClient {
|
||||
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch,
|
||||
* 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `err.error` 里的
|
||||
* 错误码,**不要**去 match `err.data` 的文案 —— 文案是后端可以随时改的。
|
||||
*
|
||||
* 导出给不走 axios 的请求用(AI 的流式接口是裸 fetch,见 utils/stream.ts)——
|
||||
* 同一个错误码从两条路进来,学生看到的结果不该有两个样子。
|
||||
*/
|
||||
export function handleGlobalApiError(code: string, message?: string) {
|
||||
if (code === "login-required") {
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
useAuthModalStore().openLoginModal()
|
||||
} else if (code === "account-disabled") {
|
||||
// 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒,
|
||||
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
|
||||
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
toast.error("账号已被禁用,请联系老师")
|
||||
} else if (code === "permission-denied") {
|
||||
toast.error(message || "权限不足")
|
||||
}
|
||||
}
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: "/api",
|
||||
withCredentials: true,
|
||||
@@ -55,22 +78,7 @@ instance.interceptors.response.use(
|
||||
const code = payload?.error?.code ?? "network-error"
|
||||
const message = payload?.error?.message ?? "Request failed"
|
||||
|
||||
// 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch,
|
||||
// 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `err.error` 里的
|
||||
// 错误码,**不要**去 match `err.data` 的文案 —— 文案是后端可以随时改的。
|
||||
if (code === "login-required") {
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
useAuthModalStore().openLoginModal()
|
||||
} else if (code === "account-disabled") {
|
||||
// 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒,
|
||||
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
|
||||
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
toast.error("账号已被禁用,请联系老师")
|
||||
} else if (code === "permission-denied") {
|
||||
toast.error(message || "权限不足")
|
||||
}
|
||||
|
||||
handleGlobalApiError(code, message)
|
||||
return Promise.reject({ error: code, data: message })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { handleGlobalApiError } from "./api"
|
||||
|
||||
export interface JSONEventStreamHandlers<T = any> {
|
||||
onMessage: (data: T, event?: string) => void
|
||||
onEvent?: (event: string) => void
|
||||
@@ -96,12 +98,21 @@ export async function consumeJSONEventStream<T = any>(
|
||||
/**
|
||||
* AI 端点的非 2xx 响应体是 JSON 不是 SSE,直接丢给上面的解析器只会抛
|
||||
* 「无法解析服务端事件数据: {...}」。后端 error.message 是英文的,按 code 换成中文。
|
||||
*
|
||||
* 登录失效 / 账号禁用走和 axios 拦截器同一套处理(弹登录框 / 提示禁用)——
|
||||
* 原来这里裸 fetch 绕过了拦截器,未登录时只显示一句「AI 分析生成失败」。
|
||||
*/
|
||||
export async function aiStreamError(response: Response) {
|
||||
async function aiStreamError(response: Response) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: { code?: string }
|
||||
error?: { code?: string; message?: string }
|
||||
} | null
|
||||
switch (body?.error?.code) {
|
||||
const code = body?.error?.code ?? "network-error"
|
||||
handleGlobalApiError(code, body?.error?.message)
|
||||
switch (code) {
|
||||
case "login-required":
|
||||
return new Error("请先登录")
|
||||
case "account-disabled":
|
||||
return new Error("账号已被禁用,请联系老师")
|
||||
case "too-many-requests":
|
||||
return new Error("AI 请求太频繁了,歇一会儿再试")
|
||||
case "hint-locked":
|
||||
@@ -114,3 +125,50 @@ export async function aiStreamError(response: Response) {
|
||||
return new Error("AI 分析生成失败")
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 流的事件形状,和 apps/api/src/services/ai.ts 的 streamChat / streamWhole 一致 */
|
||||
type AIStreamEvent =
|
||||
| { type: "delta"; content?: string }
|
||||
| { type: "error"; message?: string }
|
||||
| ({ type: "done" } & Record<string, unknown>)
|
||||
|
||||
export interface AIStreamHandlers<Done> {
|
||||
onDelta: (content: string) => void
|
||||
/** done 事件里后端并进来的额外字段(比如 AI 提示的 hintId / level) */
|
||||
onDone?: (data: Done) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起一次 AI 流式请求并读到结束。学情分析、班级分析、班级 PK、AI 提示四个入口共用 ——
|
||||
* 原来是四份几乎一样的 fetch + SSE 解析,错误处理各漏各的。
|
||||
*
|
||||
* 失败抛 Error,`message` 是能直接给用户看的中文(后端下发的错误文案本来就是固定的
|
||||
* 中文,见 services/ai.ts 的 CLIENT_ERROR_MESSAGE)。被 abort 时抛 AbortError,
|
||||
* 调用方一般用 `useAIStream` 就不用自己分辨。
|
||||
*/
|
||||
export async function streamAI<Done = Record<string, unknown>>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
handlers: AIStreamHandlers<Done>,
|
||||
) {
|
||||
const response = await fetch(`/api/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: handlers.signal,
|
||||
})
|
||||
if (!response.ok) throw await aiStreamError(response)
|
||||
await consumeJSONEventStream<AIStreamEvent>(response, {
|
||||
signal: handlers.signal,
|
||||
onMessage(event) {
|
||||
if (event.type === "delta") {
|
||||
if (event.content) handlers.onDelta(event.content)
|
||||
} else if (event.type === "error") {
|
||||
throw new Error(event.message || "AI 服务异常")
|
||||
} else if (event.type === "done") {
|
||||
handlers.onDone?.(event as Done)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user