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:
xuyueandClaude Opus 5.5 committed 2026-09-27 15:01:43 +08:00
1 parent 09030240b8
commit c43da01ff2
7 files changed
+202 -205

No files matched your search

+10 -55
View File
@@ -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<string>("")
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)
}
}
@@ -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<number | null>(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<AiHintDone>(
"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
}
}
+10 -48
View File
@@ -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<ClassComparison | null>(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)
}
}
+16 -49
View File
@@ -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