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 { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
import { useAIStream } from "shared/composables/aiStream"
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -55,10 +55,10 @@ const duration = ref<string>("")
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const hasTimeRange = ref(false)
|
const hasTimeRange = ref(false)
|
||||||
|
|
||||||
const aiLoading = ref(false)
|
const aiStream = useAIStream()
|
||||||
|
const aiLoading = aiStream.waiting
|
||||||
const aiContent = ref("")
|
const aiContent = ref("")
|
||||||
const showAIModal = ref(false)
|
const showAIModal = ref(false)
|
||||||
let aiController: AbortController | null = null
|
|
||||||
|
|
||||||
// 长时段和榜单页同一份(LONG_DURATION_OPTIONS),外加一个「全部时间」
|
// 长时段和榜单页同一份(LONG_DURATION_OPTIONS),外加一个「全部时间」
|
||||||
const timeRangeOptions: SelectOption[] = [
|
const timeRangeOptions: SelectOption[] = [
|
||||||
@@ -120,65 +120,20 @@ async function compare() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeWithAI() {
|
async function analyzeWithAI() {
|
||||||
if (aiController) {
|
|
||||||
aiController.abort()
|
|
||||||
}
|
|
||||||
const controller = new AbortController()
|
|
||||||
aiController = controller
|
|
||||||
|
|
||||||
const timeRangeLabel =
|
const timeRangeLabel =
|
||||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
|
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
|
||||||
"全部时间"
|
"全部时间"
|
||||||
|
|
||||||
showAIModal.value = true
|
showAIModal.value = true
|
||||||
aiContent.value = ""
|
aiContent.value = ""
|
||||||
aiLoading.value = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/ai/class-pk-analysis", {
|
await aiStream.run(
|
||||||
method: "POST",
|
"ai/class-pk-analysis",
|
||||||
headers: { "Content-Type": "application/json" },
|
{ comparisons: comparisons.value, timeRangeLabel },
|
||||||
body: JSON.stringify({
|
{ onDelta: (content) => (aiContent.value += content) },
|
||||||
comparisons: comparisons.value,
|
)
|
||||||
timeRangeLabel,
|
} catch (error) {
|
||||||
}),
|
message.error((error as Error).message)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract"
|
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 { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||||
import {
|
import {
|
||||||
submissionCaseResults,
|
submissionCaseResults,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import type { Submission } from "utils/types"
|
import type { Submission } from "utils/types"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
import { useAIStream } from "shared/composables/aiStream"
|
||||||
import { submitHintFeedback } from "oj/api"
|
import { submitHintFeedback } from "oj/api"
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
@@ -34,7 +34,9 @@ const theme = useThemeVars()
|
|||||||
// 逐字显示改在这边模拟。
|
// 逐字显示改在这边模拟。
|
||||||
const hintTarget = ref("")
|
const hintTarget = ref("")
|
||||||
const hintContent = ref("")
|
const hintContent = ref("")
|
||||||
const hintLoading = ref(false)
|
const hintStream = useAIStream()
|
||||||
|
// 整条流跑完才算结束:提示是一次推全文,没有「第一个字到了」的中间态
|
||||||
|
const hintLoading = hintStream.running
|
||||||
const hintError = ref("")
|
const hintError = ref("")
|
||||||
// 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮
|
// 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮
|
||||||
const hintId = ref<number | null>(null)
|
const hintId = ref<number | null>(null)
|
||||||
@@ -147,51 +149,34 @@ const showAIHint = computed(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.submission?.id,
|
() => props.submission?.id,
|
||||||
() => {
|
() => {
|
||||||
|
// 上一次提交的提示还在生成的话掐掉,不然它会写进新提交的面板里
|
||||||
|
hintStream.abort()
|
||||||
resetHint()
|
resetHint()
|
||||||
hintLoading.value = false
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看
|
// more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看
|
||||||
// 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准
|
// 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准
|
||||||
async function fetchHint(submissionId: string, more = false) {
|
async function fetchHint(submissionId: string, more = false) {
|
||||||
hintLoading.value = true
|
|
||||||
resetHint()
|
resetHint()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/ai/hint", {
|
await hintStream.run<AiHintDone>(
|
||||||
method: "POST",
|
"ai/hint",
|
||||||
headers: { "Content-Type": "application/json" },
|
{ submissionId, more },
|
||||||
body: JSON.stringify({ submissionId, more }),
|
{
|
||||||
})
|
onDelta(content) {
|
||||||
|
hintTarget.value += content
|
||||||
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
|
|
||||||
startTyping()
|
startTyping()
|
||||||
} else if (data.type === "done") {
|
},
|
||||||
|
onDone(data) {
|
||||||
hintId.value = data.hintId ?? null
|
hintId.value = data.hintId ?? null
|
||||||
hintLevel.value = data.level ?? null
|
hintLevel.value = data.level ?? null
|
||||||
hintCanEscalate.value = data.canEscalate === true
|
hintCanEscalate.value = data.canEscalate === true
|
||||||
} else if (data.type === "error") {
|
},
|
||||||
hintError.value = data.message || "AI 提示生成失败"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
} catch (e: any) {
|
} catch (error) {
|
||||||
hintError.value = e.message || "请求失败"
|
hintError.value = (error as Error).message
|
||||||
} finally {
|
|
||||||
hintLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { useUserStore } from "shared/store/user"
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
import { useAIStream } from "shared/composables/aiStream"
|
||||||
|
|
||||||
const GRADES = [20, 25]
|
const GRADES = [20, 25]
|
||||||
|
|
||||||
@@ -102,10 +102,10 @@ const showClassDetailModal = ref(false)
|
|||||||
const classDetailData = ref<ClassComparison | null>(null)
|
const classDetailData = ref<ClassComparison | null>(null)
|
||||||
const classDetailLoading = ref(false)
|
const classDetailLoading = ref(false)
|
||||||
|
|
||||||
const classDetailAiLoading = ref(false)
|
const classDetailAiStream = useAIStream()
|
||||||
|
const classDetailAiLoading = classDetailAiStream.waiting
|
||||||
const classDetailAiContent = ref("")
|
const classDetailAiContent = ref("")
|
||||||
const showClassDetailAiModal = ref(false)
|
const showClassDetailAiModal = ref(false)
|
||||||
let classDetailAiController: AbortController | null = null
|
|
||||||
|
|
||||||
async function loadClassDetail(className: string) {
|
async function loadClassDetail(className: string) {
|
||||||
showClassDetailModal.value = true
|
showClassDetailModal.value = true
|
||||||
@@ -123,55 +123,17 @@ async function loadClassDetail(className: string) {
|
|||||||
|
|
||||||
async function analyzeSingleClassWithAI() {
|
async function analyzeSingleClassWithAI() {
|
||||||
if (!classDetailData.value) return
|
if (!classDetailData.value) return
|
||||||
if (classDetailAiController) classDetailAiController.abort()
|
|
||||||
const controller = new AbortController()
|
|
||||||
classDetailAiController = controller
|
|
||||||
|
|
||||||
showClassDetailModal.value = false
|
showClassDetailModal.value = false
|
||||||
showClassDetailAiModal.value = true
|
showClassDetailAiModal.value = true
|
||||||
classDetailAiContent.value = ""
|
classDetailAiContent.value = ""
|
||||||
classDetailAiLoading.value = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/ai/class-analysis", {
|
await classDetailAiStream.run(
|
||||||
method: "POST",
|
"ai/class-analysis",
|
||||||
headers: { "Content-Type": "application/json" },
|
{ comparison: classDetailData.value },
|
||||||
body: JSON.stringify({ comparison: classDetailData.value }),
|
{ onDelta: (content) => (classDetailAiContent.value += content) },
|
||||||
signal: controller.signal,
|
)
|
||||||
})
|
} catch (error) {
|
||||||
if (!response.ok) throw await aiStreamError(response)
|
message.error((error as Error).message)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-49
@@ -1,5 +1,5 @@
|
|||||||
import type { DetailsData, DurationData, SolvedProblem } from "utils/types"
|
import type { DetailsData, DurationData, SolvedProblem } from "utils/types"
|
||||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
import { streamAI } from "utils/stream"
|
||||||
import {
|
import {
|
||||||
getAIDetailData,
|
getAIDetailData,
|
||||||
getAIDurationData,
|
getAIDurationData,
|
||||||
@@ -128,10 +128,10 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
|
|
||||||
let aiController: AbortController | null = null
|
let aiController: AbortController | null = null
|
||||||
|
|
||||||
|
// 不用 useAIStream:store 没有会卸载的作用域,而且 loading.ai 要和其它几个
|
||||||
|
// loading 放在同一个 reactive 里给页面用
|
||||||
async function fetchAIAnalysis() {
|
async function fetchAIAnalysis() {
|
||||||
if (aiController) {
|
aiController?.abort()
|
||||||
aiController.abort()
|
|
||||||
}
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
aiController = controller
|
aiController = controller
|
||||||
|
|
||||||
@@ -139,58 +139,25 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
mdContent.value = ""
|
mdContent.value = ""
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/ai/analysis", {
|
await streamAI(
|
||||||
method: "POST",
|
"ai/analysis",
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({
|
|
||||||
start: rangeStart.value,
|
start: rangeStart.value,
|
||||||
end: rangeEnd.value,
|
end: rangeEnd.value,
|
||||||
duration: duration.value,
|
duration: duration.value,
|
||||||
username: targetUsername.value || undefined,
|
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 {
|
signal: controller.signal,
|
||||||
type?: string
|
onDelta(content) {
|
||||||
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) {
|
|
||||||
loading.ai = false
|
loading.ai = false
|
||||||
}
|
mdContent.value += content
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) return
|
||||||
return
|
mdContent.value = `生成失败:${(error as Error).message}`
|
||||||
}
|
|
||||||
console.error("生成 AI 分析失败", error)
|
|
||||||
const message = error?.message || "生成失败,请稍后再试"
|
|
||||||
mdContent.value = `生成失败:${message}`
|
|
||||||
} finally {
|
} finally {
|
||||||
if (aiController === controller) {
|
if (aiController === controller) {
|
||||||
aiController = null
|
aiController = null
|
||||||
|
|||||||
@@ -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<Done = Record<string, unknown>>(
|
||||||
|
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<Done>(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 }
|
||||||
|
}
|
||||||
+24
-16
@@ -27,6 +27,29 @@ interface ApiClient {
|
|||||||
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T>
|
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({
|
const instance = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api",
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
@@ -55,22 +78,7 @@ instance.interceptors.response.use(
|
|||||||
const code = payload?.error?.code ?? "network-error"
|
const code = payload?.error?.code ?? "network-error"
|
||||||
const message = payload?.error?.message ?? "Request failed"
|
const message = payload?.error?.message ?? "Request failed"
|
||||||
|
|
||||||
// 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch,
|
handleGlobalApiError(code, message)
|
||||||
// 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `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 || "权限不足")
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject({ error: code, data: message })
|
return Promise.reject({ error: code, data: message })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { handleGlobalApiError } from "./api"
|
||||||
|
|
||||||
export interface JSONEventStreamHandlers<T = any> {
|
export interface JSONEventStreamHandlers<T = any> {
|
||||||
onMessage: (data: T, event?: string) => void
|
onMessage: (data: T, event?: string) => void
|
||||||
onEvent?: (event: string) => void
|
onEvent?: (event: string) => void
|
||||||
@@ -96,12 +98,21 @@ export async function consumeJSONEventStream<T = any>(
|
|||||||
/**
|
/**
|
||||||
* AI 端点的非 2xx 响应体是 JSON 不是 SSE,直接丢给上面的解析器只会抛
|
* AI 端点的非 2xx 响应体是 JSON 不是 SSE,直接丢给上面的解析器只会抛
|
||||||
* 「无法解析服务端事件数据: {...}」。后端 error.message 是英文的,按 code 换成中文。
|
* 「无法解析服务端事件数据: {...}」。后端 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 {
|
const body = (await response.json().catch(() => null)) as {
|
||||||
error?: { code?: string }
|
error?: { code?: string; message?: string }
|
||||||
} | null
|
} | 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":
|
case "too-many-requests":
|
||||||
return new Error("AI 请求太频繁了,歇一会儿再试")
|
return new Error("AI 请求太频繁了,歇一会儿再试")
|
||||||
case "hint-locked":
|
case "hint-locked":
|
||||||
@@ -114,3 +125,50 @@ export async function aiStreamError(response: Response) {
|
|||||||
return new Error("AI 分析生成失败")
|
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