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 @@ + 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() + } +}