This commit is contained in:
@@ -1,25 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-spin :show="aiStore.loading.ai">
|
<n-spin :show="aiStore.loading.ai">
|
||||||
<div>AI 小助手友情提醒</div>
|
<div class="container">
|
||||||
|
<MdPreview :model-value="aiStore.mdContent" />
|
||||||
|
</div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAIStore } from "~/oj/store/ai"
|
import { useAIStore } from "~/oj/store/ai"
|
||||||
|
import { MdPreview } from "md-editor-v3"
|
||||||
|
import "md-editor-v3/lib/preview.css"
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [
|
() => [aiStore.loading.details, aiStore.loading.weekly],
|
||||||
aiStore.loading.details,
|
(newVal) => {
|
||||||
aiStore.loading.weekly,
|
if (newVal.every((val) => val === false)) {
|
||||||
],
|
|
||||||
(newVal, oldVal) => {
|
|
||||||
if (!oldVal) return
|
|
||||||
const loaded = newVal.some((val, idx) => val === false && oldVal[idx] === true)
|
|
||||||
if (loaded) {
|
|
||||||
aiStore.fetchAIAnalysis()
|
aiStore.fetchAIAnalysis()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
<n-data-table
|
<n-data-table
|
||||||
v-if="aiStore.detailsData.solved.length"
|
v-if="aiStore.detailsData.solved.length"
|
||||||
striped
|
striped
|
||||||
:max-height="400"
|
|
||||||
:data="aiStore.detailsData.solved"
|
:data="aiStore.detailsData.solved"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import { DIFFICULTY } from "utils/constants"
|
|||||||
import { getACRate } from "utils/functions"
|
import { getACRate } from "utils/functions"
|
||||||
import http from "utils/http"
|
import http from "utils/http"
|
||||||
import {
|
import {
|
||||||
DetailsData,
|
|
||||||
Problem,
|
Problem,
|
||||||
Submission,
|
Submission,
|
||||||
SubmissionListPayload,
|
SubmissionListPayload,
|
||||||
SubmitCodePayload,
|
SubmitCodePayload,
|
||||||
WeeklyData,
|
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
|
|
||||||
function filterResult(result: Problem) {
|
function filterResult(result: Problem) {
|
||||||
@@ -258,13 +256,3 @@ export function getAIWeeklyData(
|
|||||||
) {
|
) {
|
||||||
return http.get("ai/weekly", { params: { end, duration, username } })
|
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 { 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", () => {
|
export const useAIStore = defineStore("ai", () => {
|
||||||
const duration = ref("months:6")
|
const duration = ref("months:6")
|
||||||
@@ -22,6 +24,8 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
ai: false,
|
ai: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mdContent = ref("")
|
||||||
|
|
||||||
const theFirstPerson = computed(() => {
|
const theFirstPerson = computed(() => {
|
||||||
return !!username.value ? username.value : "你"
|
return !!username.value ? username.value : "你"
|
||||||
})
|
})
|
||||||
@@ -55,13 +59,85 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
loading.weekly = false
|
loading.weekly = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let aiController: AbortController | null = null
|
||||||
|
|
||||||
async function fetchAIAnalysis() {
|
async function fetchAIAnalysis() {
|
||||||
|
if (aiController) {
|
||||||
|
aiController.abort()
|
||||||
|
}
|
||||||
|
const controller = new AbortController()
|
||||||
|
aiController = controller
|
||||||
|
|
||||||
loading.ai = true
|
loading.ai = true
|
||||||
const res = await getAIAnalysis(detailsData, weeklyData.value)
|
mdContent.value = ""
|
||||||
console.log(res.data)
|
|
||||||
loading.ai = false
|
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 {
|
return {
|
||||||
fetchWeeklyData,
|
fetchWeeklyData,
|
||||||
fetchDetailsData,
|
fetchDetailsData,
|
||||||
@@ -72,5 +148,6 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
username,
|
username,
|
||||||
theFirstPerson,
|
theFirstPerson,
|
||||||
loading,
|
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() {
|
// function getChromeVersion() {
|
||||||
// var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)
|
// var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)
|
||||||
// return raw ? parseInt(raw[2], 10) : 0
|
// 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