From c43da01ff2e1afd415ab146923d2450eb3b7e5cf Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 15:01:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(AI):=20=E5=9B=9B=E4=BB=BD=20SSE=20?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=BB=A3=E7=A0=81=E5=90=88=E6=88=90=20stream?= =?UTF-8?q?AI=20+=20useAIStream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学情分析、班级分析、班级 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 --- apps/web/src/oj/class/pk.vue | 65 +++---------------- .../problem/components/SubmissionResult.vue | 53 ++++++--------- apps/web/src/oj/rank/list.vue | 58 +++-------------- apps/web/src/oj/store/ai.ts | 65 +++++-------------- apps/web/src/shared/composables/aiStream.ts | 62 ++++++++++++++++++ apps/web/src/utils/api.ts | 40 +++++++----- apps/web/src/utils/stream.ts | 64 +++++++++++++++++- 7 files changed, 202 insertions(+), 205 deletions(-) create mode 100644 apps/web/src/shared/composables/aiStream.ts diff --git a/apps/web/src/oj/class/pk.vue b/apps/web/src/oj/class/pk.vue index 81036b5..7cea48f 100644 --- a/apps/web/src/oj/class/pk.vue +++ b/apps/web/src/oj/class/pk.vue @@ -12,7 +12,7 @@ import { Bar, Radar } from "vue-chartjs" import { useBreakpoints } from "shared/composables/breakpoints" import { MdPreview } from "md-editor-v3" import "md-editor-v3/lib/preview.css" -import { aiStreamError, consumeJSONEventStream } from "utils/stream" +import { useAIStream } from "shared/composables/aiStream" import { Chart as ChartJS, CategoryScale, @@ -55,10 +55,10 @@ const duration = ref("") const loading = ref(false) const hasTimeRange = ref(false) -const aiLoading = ref(false) +const aiStream = useAIStream() +const aiLoading = aiStream.waiting const aiContent = ref("") const showAIModal = ref(false) -let aiController: AbortController | null = null // 长时段和榜单页同一份(LONG_DURATION_OPTIONS),外加一个「全部时间」 const timeRangeOptions: SelectOption[] = [ @@ -120,65 +120,20 @@ async function compare() { } async function analyzeWithAI() { - if (aiController) { - aiController.abort() - } - const controller = new AbortController() - aiController = controller - const timeRangeLabel = timeRangeOptions.find((o) => o.value === duration.value)?.label ?? "全部时间" showAIModal.value = true aiContent.value = "" - aiLoading.value = true - try { - const response = await fetch("/api/ai/class-pk-analysis", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - comparisons: comparisons.value, - timeRangeLabel, - }), - signal: controller.signal, - }) - - if (!response.ok) throw await aiStreamError(response) - - let hasStarted = false - - await consumeJSONEventStream(response, { - signal: controller.signal, - onEvent(event) { - if (event === "end" && !hasStarted) aiLoading.value = false - }, - onMessage(payload) { - const parsed = payload as { - type?: string - content?: string - message?: string - } - if (parsed.type === "delta" && parsed.content) { - if (!hasStarted) { - hasStarted = true - aiLoading.value = false - } - aiContent.value += parsed.content - } else if (parsed.type === "error") { - throw new Error(parsed.message || "AI 服务异常") - } else if (parsed.type === "done" && !hasStarted) { - aiLoading.value = false - } - }, - }) - } catch (error: any) { - if (controller.signal.aborted) return - message.error(error?.message || "AI 分析失败,请稍后再试") - aiLoading.value = false - } finally { - if (aiController === controller) aiController = null + await aiStream.run( + "ai/class-pk-analysis", + { comparisons: comparisons.value, timeRangeLabel }, + { onDelta: (content) => (aiContent.value += content) }, + ) + } catch (error) { + message.error((error as Error).message) } } diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index 7586ad6..e3a3abc 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -2,7 +2,7 @@ import { Icon } from "@iconify/vue" import { useThemeVars } from "naive-ui" import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract" -import type { JudgeCaseResult } from "@oj2/contract" +import type { AiHintDone, JudgeCaseResult } from "@oj2/contract" import { JUDGE_STATUS, SubmissionStatus } from "utils/constants" import { submissionCaseResults, @@ -14,7 +14,7 @@ import { import type { Submission } from "utils/types" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import { useProblemStore } from "oj/store/problem" -import { aiStreamError, consumeJSONEventStream } from "utils/stream" +import { useAIStream } from "shared/composables/aiStream" import { submitHintFeedback } from "oj/api" import { MdPreview } from "md-editor-v3" import "md-editor-v3/lib/preview.css" @@ -34,7 +34,9 @@ const theme = useThemeVars() // 逐字显示改在这边模拟。 const hintTarget = ref("") const hintContent = ref("") -const hintLoading = ref(false) +const hintStream = useAIStream() +// 整条流跑完才算结束:提示是一次推全文,没有「第一个字到了」的中间态 +const hintLoading = hintStream.running const hintError = ref("") // 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮 const hintId = ref(null) @@ -147,51 +149,34 @@ const showAIHint = computed(() => { watch( () => props.submission?.id, () => { + // 上一次提交的提示还在生成的话掐掉,不然它会写进新提交的面板里 + hintStream.abort() resetHint() - hintLoading.value = false }, ) // more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看 // 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准 async function fetchHint(submissionId: string, more = false) { - hintLoading.value = true resetHint() - try { - const response = await fetch("/api/ai/hint", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ submissionId, more }), - }) - - if (!response.ok) throw await aiStreamError(response) - - await consumeJSONEventStream(response, { - onMessage: (data: { - type: string - content?: string - message?: string - hintId?: number | null - level?: number - canEscalate?: boolean - }) => { - if (data.type === "delta" && data.content) { - hintTarget.value += data.content + await hintStream.run( + "ai/hint", + { submissionId, more }, + { + onDelta(content) { + hintTarget.value += content startTyping() - } else if (data.type === "done") { + }, + onDone(data) { hintId.value = data.hintId ?? null hintLevel.value = data.level ?? null hintCanEscalate.value = data.canEscalate === true - } else if (data.type === "error") { - hintError.value = data.message || "AI 提示生成失败" - } + }, }, - }) - } catch (e: any) { - hintError.value = e.message || "请求失败" - } finally { - hintLoading.value = false + ) + } catch (error) { + hintError.value = (error as Error).message } } diff --git a/apps/web/src/oj/rank/list.vue b/apps/web/src/oj/rank/list.vue index 9132f6c..0d9ebfa 100644 --- a/apps/web/src/oj/rank/list.vue +++ b/apps/web/src/oj/rank/list.vue @@ -29,7 +29,7 @@ import { useUserStore } from "shared/store/user" import { Icon } from "@iconify/vue" import { MdPreview } from "md-editor-v3" import "md-editor-v3/lib/preview.css" -import { aiStreamError, consumeJSONEventStream } from "utils/stream" +import { useAIStream } from "shared/composables/aiStream" const GRADES = [20, 25] @@ -102,10 +102,10 @@ const showClassDetailModal = ref(false) const classDetailData = ref(null) const classDetailLoading = ref(false) -const classDetailAiLoading = ref(false) +const classDetailAiStream = useAIStream() +const classDetailAiLoading = classDetailAiStream.waiting const classDetailAiContent = ref("") const showClassDetailAiModal = ref(false) -let classDetailAiController: AbortController | null = null async function loadClassDetail(className: string) { showClassDetailModal.value = true @@ -123,55 +123,17 @@ async function loadClassDetail(className: string) { async function analyzeSingleClassWithAI() { if (!classDetailData.value) return - if (classDetailAiController) classDetailAiController.abort() - const controller = new AbortController() - classDetailAiController = controller - showClassDetailModal.value = false showClassDetailAiModal.value = true classDetailAiContent.value = "" - classDetailAiLoading.value = true - try { - const response = await fetch("/api/ai/class-analysis", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comparison: classDetailData.value }), - signal: controller.signal, - }) - if (!response.ok) throw await aiStreamError(response) - - let hasStarted = false - await consumeJSONEventStream(response, { - signal: controller.signal, - onEvent(event) { - if (event === "end" && !hasStarted) classDetailAiLoading.value = false - }, - onMessage(payload) { - const parsed = payload as { - type?: string - content?: string - message?: string - } - if (parsed.type === "delta" && parsed.content) { - if (!hasStarted) { - hasStarted = true - classDetailAiLoading.value = false - } - classDetailAiContent.value += parsed.content - } else if (parsed.type === "error") { - throw new Error(parsed.message || "AI 服务异常") - } else if (parsed.type === "done" && !hasStarted) { - classDetailAiLoading.value = false - } - }, - }) - } catch (error: any) { - if (controller.signal.aborted) return - message.error(error?.message || "AI 分析失败,请稍后再试") - classDetailAiLoading.value = false - } finally { - if (classDetailAiController === controller) classDetailAiController = null + await classDetailAiStream.run( + "ai/class-analysis", + { comparison: classDetailData.value }, + { onDelta: (content) => (classDetailAiContent.value += content) }, + ) + } catch (error) { + message.error((error as Error).message) } } diff --git a/apps/web/src/oj/store/ai.ts b/apps/web/src/oj/store/ai.ts index 9a5dd97..a764908 100644 --- a/apps/web/src/oj/store/ai.ts +++ b/apps/web/src/oj/store/ai.ts @@ -1,5 +1,5 @@ import type { DetailsData, DurationData, SolvedProblem } from "utils/types" -import { aiStreamError, consumeJSONEventStream } from "utils/stream" +import { streamAI } from "utils/stream" import { getAIDetailData, getAIDurationData, @@ -128,10 +128,10 @@ export const useAIStore = defineStore("ai", () => { let aiController: AbortController | null = null + // 不用 useAIStream:store 没有会卸载的作用域,而且 loading.ai 要和其它几个 + // loading 放在同一个 reactive 里给页面用 async function fetchAIAnalysis() { - if (aiController) { - aiController.abort() - } + aiController?.abort() const controller = new AbortController() aiController = controller @@ -139,58 +139,25 @@ export const useAIStore = defineStore("ai", () => { mdContent.value = "" try { - const response = await fetch("/api/ai/analysis", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + await streamAI( + "ai/analysis", + { start: rangeStart.value, end: rangeEnd.value, duration: duration.value, username: targetUsername.value || undefined, - }), - signal: controller.signal, - }) - - if (!response.ok) { - throw await aiStreamError(response) - } - - let hasStarted = false - - await consumeJSONEventStream(response, { - signal: controller.signal, - onEvent(event) { - if (event === "end" && !hasStarted) { - loading.ai = false - } }, - onMessage(payload) { - const parsed = payload as { - type?: string - content?: string - message?: string - } - - if (parsed.type === "delta" && parsed.content) { - if (!hasStarted) { - hasStarted = true - loading.ai = false - } - mdContent.value += parsed.content - } else if (parsed.type === "error") { - throw new Error(parsed.message || "AI 服务异常") - } else if (parsed.type === "done" && !hasStarted) { + { + signal: controller.signal, + onDelta(content) { loading.ai = false - } + mdContent.value += content + }, }, - }) - } catch (error: any) { - if (controller.signal.aborted) { - return - } - console.error("生成 AI 分析失败", error) - const message = error?.message || "生成失败,请稍后再试" - mdContent.value = `生成失败:${message}` + ) + } catch (error) { + if (controller.signal.aborted) return + mdContent.value = `生成失败:${(error as Error).message}` } finally { if (aiController === controller) { aiController = null diff --git a/apps/web/src/shared/composables/aiStream.ts b/apps/web/src/shared/composables/aiStream.ts new file mode 100644 index 0000000..29fea14 --- /dev/null +++ b/apps/web/src/shared/composables/aiStream.ts @@ -0,0 +1,62 @@ +import { streamAI } from "utils/stream" + +/** + * 组件里发 AI 流式请求:同一时刻只跑一条(再点一次会先掐掉上一条),组件卸载时 + * 自动中止 —— 原来班级分析、班级 PK 两处离开页面后流还在跑,白占一个 AI 名额。 + * + * - `waiting`:请求发出、第一个字还没到。按钮 loading、占位转圈用它 + * - `running`:整条流还没结束 + * + * `run` 失败时抛 Error(message 可直接给用户看);被中止时静默返回,不抛。 + */ +export function useAIStream() { + const waiting = ref(false) + const running = ref(false) + let controller: AbortController | null = null + + function abort() { + controller?.abort() + controller = null + waiting.value = false + running.value = false + } + + async function run>( + path: string, + body: unknown, + handlers: { + onDelta: (content: string) => void + onDone?: (data: Done) => void + }, + ) { + abort() + const own = new AbortController() + controller = own + waiting.value = true + running.value = true + try { + await streamAI(path, body, { + ...handlers, + signal: own.signal, + onDelta(content) { + waiting.value = false + handlers.onDelta(content) + }, + }) + } catch (error) { + if (own.signal.aborted) return + throw error + } finally { + // 被下一次 run 顶掉的那条不碰状态,状态归新的那条管 + if (controller === own) { + controller = null + waiting.value = false + running.value = false + } + } + } + + if (getCurrentScope()) onScopeDispose(abort) + + return { waiting, running, run, abort } +} diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts index 87a2ec4..0ec9f5e 100644 --- a/apps/web/src/utils/api.ts +++ b/apps/web/src/utils/api.ts @@ -27,6 +27,29 @@ interface ApiClient { delete(url: string, config?: AxiosRequestConfig): Promise } +/** + * 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 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 }) }, ) diff --git a/apps/web/src/utils/stream.ts b/apps/web/src/utils/stream.ts index 0b89227..8441a55 100644 --- a/apps/web/src/utils/stream.ts +++ b/apps/web/src/utils/stream.ts @@ -1,3 +1,5 @@ +import { handleGlobalApiError } from "./api" + export interface JSONEventStreamHandlers { onMessage: (data: T, event?: string) => void onEvent?: (event: string) => void @@ -96,12 +98,21 @@ export async function consumeJSONEventStream( /** * 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) + +export interface AIStreamHandlers { + 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>( + path: string, + body: unknown, + handlers: AIStreamHandlers, +) { + 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(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) + } + }, + }) +}