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