This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
<n-data-table
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
striped
|
||||
:max-height="400"
|
||||
:data="aiStore.detailsData.solved"
|
||||
:columns="columns"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,11 +59,83 @@ 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 {
|
||||
@@ -72,5 +148,6 @@ export const useAIStore = defineStore("ai", () => {
|
||||
username,
|
||||
theFirstPerson,
|
||||
loading,
|
||||
mdContent,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
94
src/utils/stream.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user