From 0b652aa30165a1bab374cb4d746aa38f1ae9c2a9 Mon Sep 17 00:00:00 2001
From: yuetsh <517252939@qq.com>
Date: Wed, 24 Sep 2025 18:28:42 +0800
Subject: [PATCH] stream returns
---
src/oj/ai/components/AI.vue | 24 ++++----
src/oj/ai/components/Details.vue | 1 -
src/oj/api.ts | 12 ----
src/oj/store/ai.ts | 87 +++++++++++++++++++++++++++--
src/utils/functions.ts | 8 +++
src/utils/stream.ts | 94 ++++++++++++++++++++++++++++++++
6 files changed, 197 insertions(+), 29 deletions(-)
create mode 100644 src/utils/stream.ts
diff --git a/src/oj/ai/components/AI.vue b/src/oj/ai/components/AI.vue
index 4325cfb..13e7567 100644
--- a/src/oj/ai/components/AI.vue
+++ b/src/oj/ai/components/AI.vue
@@ -1,25 +1,27 @@
- AI 小助手友情提醒
+
+
+
+
diff --git a/src/oj/ai/components/Details.vue b/src/oj/ai/components/Details.vue
index 9b4b6f7..783d51b 100644
--- a/src/oj/ai/components/Details.vue
+++ b/src/oj/ai/components/Details.vue
@@ -33,7 +33,6 @@
diff --git a/src/oj/api.ts b/src/oj/api.ts
index 01989d8..4e1d385 100644
--- a/src/oj/api.ts
+++ b/src/oj/api.ts
@@ -2,12 +2,10 @@ import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions"
import http from "utils/http"
import {
- DetailsData,
Problem,
Submission,
SubmissionListPayload,
SubmitCodePayload,
- WeeklyData,
} from "utils/types"
function filterResult(result: Problem) {
@@ -258,13 +256,3 @@ export function getAIWeeklyData(
) {
return http.get("ai/weekly", { params: { end, duration, username } })
}
-
-export function getAIAnalysis(
- detailsData: DetailsData,
- weeklyData: WeeklyData[],
-) {
- return http.post("ai/analysis", {
- details: detailsData,
- weekly: weeklyData,
- })
-}
diff --git a/src/oj/store/ai.ts b/src/oj/store/ai.ts
index 1581750..06af53c 100644
--- a/src/oj/store/ai.ts
+++ b/src/oj/store/ai.ts
@@ -1,5 +1,7 @@
import { DetailsData, WeeklyData } from "~/utils/types"
-import { getAIAnalysis, getAIDetailData, getAIWeeklyData } from "../api"
+import { consumeJSONEventStream } from "~/utils/stream"
+import { getAIDetailData, getAIWeeklyData } from "../api"
+import { getCSRFToken } from "~/utils/functions"
export const useAIStore = defineStore("ai", () => {
const duration = ref("months:6")
@@ -22,6 +24,8 @@ export const useAIStore = defineStore("ai", () => {
ai: false,
})
+ const mdContent = ref("")
+
const theFirstPerson = computed(() => {
return !!username.value ? username.value : "你"
})
@@ -55,13 +59,85 @@ export const useAIStore = defineStore("ai", () => {
loading.weekly = false
}
+ let aiController: AbortController | null = null
+
async function fetchAIAnalysis() {
+ if (aiController) {
+ aiController.abort()
+ }
+ const controller = new AbortController()
+ aiController = controller
+
loading.ai = true
- const res = await getAIAnalysis(detailsData, weeklyData.value)
- console.log(res.data)
- loading.ai = false
+ mdContent.value = ""
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+ const csrfToken = getCSRFToken()
+ if (csrfToken) {
+ headers["X-CSRFToken"] = csrfToken
+ }
+
+ try {
+ const response = await fetch("/api/ai/analysis", {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ details: detailsData,
+ weekly: weeklyData.value,
+ }),
+ signal: controller.signal,
+ })
+
+ if (!response.ok) {
+ throw new Error("AI 分析生成失败")
+ }
+
+ 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) {
+ loading.ai = false
+ }
+ },
+ })
+ } catch (error: any) {
+ if (controller.signal.aborted) {
+ return
+ }
+ console.error("生成 AI 分析失败", error)
+ const message = error?.message || "生成失败,请稍后再试"
+ mdContent.value = `生成失败:${message}`
+ } finally {
+ if (aiController === controller) {
+ aiController = null
+ loading.ai = false
+ }
+ }
}
-
+
return {
fetchWeeklyData,
fetchDetailsData,
@@ -72,5 +148,6 @@ export const useAIStore = defineStore("ai", () => {
username,
theFirstPerson,
loading,
+ mdContent,
}
})
diff --git a/src/utils/functions.ts b/src/utils/functions.ts
index 28d44f4..5ea74aa 100644
--- a/src/utils/functions.ts
+++ b/src/utils/functions.ts
@@ -183,6 +183,14 @@ export function decode(bytes?: string) {
)
}
+export function getCSRFToken() {
+ if (typeof document === "undefined") {
+ return ""
+ }
+ const match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/)
+ return match ? decodeURIComponent(match[1]) : ""
+}
+
// function getChromeVersion() {
// var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)
// return raw ? parseInt(raw[2], 10) : 0
diff --git a/src/utils/stream.ts b/src/utils/stream.ts
new file mode 100644
index 0000000..fc6b6ef
--- /dev/null
+++ b/src/utils/stream.ts
@@ -0,0 +1,94 @@
+export interface JSONEventStreamHandlers {
+ onMessage: (data: T, event?: string) => void
+ onEvent?: (event: string) => void
+ signal?: AbortSignal | null
+}
+
+export async function consumeJSONEventStream(
+ response: Response,
+ handlers: JSONEventStreamHandlers,
+) {
+ if (!response.body) {
+ throw new Error("当前环境不支持可读流")
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder("utf-8")
+ let buffer = ""
+
+ const { onMessage, onEvent, signal } = handlers
+
+ const handleEvent = (raw: string) => {
+ const lines = raw.split("\n")
+ let eventName: string | undefined
+ const dataLines: string[] = []
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+ if (trimmed.startsWith("event:")) {
+ eventName = trimmed.slice(6).trim()
+ } else if (trimmed.startsWith("data:")) {
+ dataLines.push(trimmed.slice(5).trim())
+ }
+ }
+
+ if (dataLines.length === 0) {
+ if (eventName && onEvent) {
+ onEvent(eventName)
+ }
+ return
+ }
+
+ const payloadStr = dataLines.join("\n")
+
+ let parsed: T
+ try {
+ parsed = JSON.parse(payloadStr)
+ } catch (error) {
+ throw new Error(`无法解析服务端事件数据: ${payloadStr}`)
+ }
+
+ onMessage(parsed, eventName)
+ }
+
+ const processBuffer = (flush = false) => {
+ let idx = buffer.indexOf("\n\n")
+ while (idx !== -1) {
+ const rawEvent = buffer.slice(0, idx)
+ buffer = buffer.slice(idx + 2)
+ if (rawEvent.trim()) {
+ handleEvent(rawEvent)
+ }
+ idx = buffer.indexOf("\n\n")
+ }
+
+ if (flush && buffer.trim()) {
+ handleEvent(buffer.trim())
+ buffer = ""
+ }
+ }
+
+ try {
+ while (true) {
+ if (signal?.aborted) {
+ await reader.cancel()
+ break
+ }
+
+ const { value, done } = await reader.read()
+ if (value) {
+ buffer += decoder.decode(value, { stream: true })
+ processBuffer()
+ }
+
+ if (done) {
+ buffer += decoder.decode()
+ processBuffer(true)
+ break
+ }
+ }
+ } finally {
+ reader.releaseLock()
+ }
+}