stream returns
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2025-09-24 18:28:42 +08:00
parent 0a9d49b526
commit 0b652aa301
6 changed files with 197 additions and 29 deletions

View File

@@ -1,25 +1,27 @@
<template>
<n-spin :show="aiStore.loading.ai">
<div>AI 小助手友情提醒</div>
<div class="container">
<MdPreview :model-value="aiStore.mdContent" />
</div>
</n-spin>
</template>
<script setup lang="ts">
import { useAIStore } from "~/oj/store/ai"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
const aiStore = useAIStore()
watch(
() => [
aiStore.loading.details,
aiStore.loading.weekly,
],
(newVal, oldVal) => {
if (!oldVal) return
const loaded = newVal.some((val, idx) => val === false && oldVal[idx] === true)
if (loaded) {
() => [aiStore.loading.details, aiStore.loading.weekly],
(newVal) => {
if (newVal.every((val) => val === false)) {
aiStore.fetchAIAnalysis()
}
},
{ immediate: true },
)
</script>
<style scoped>
.container {
min-height: 200px;
}
</style>

View File

@@ -33,7 +33,6 @@
<n-data-table
v-if="aiStore.detailsData.solved.length"
striped
:max-height="400"
:data="aiStore.detailsData.solved"
:columns="columns"
/>

View File

@@ -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,
})
}

View File

@@ -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<string, string> = {
"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,
}
})

View File

@@ -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

94
src/utils/stream.ts Normal file
View File

@@ -0,0 +1,94 @@
export interface JSONEventStreamHandlers<T = any> {
onMessage: (data: T, event?: string) => void
onEvent?: (event: string) => void
signal?: AbortSignal | null
}
export async function consumeJSONEventStream<T = any>(
response: Response,
handlers: JSONEventStreamHandlers<T>,
) {
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()
}
}