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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user