feat(ai): V2 深度增强 — SSE 流式/全局助手/内容安全/多角色覆盖
对标 Khanmigo/Duolingo Max/Squirrel AI/Century Tech 实现: - SSE 流式响应:createAiChatCompletionStream AsyncGenerator + /api/ai/chat/stream SSE 端点 + useAiChatStream hook(AbortController 停止生成 + localStorage 持久化) - Markdown 渲染:AiMarkdownRenderer(react-markdown + remark-gfm + 代码块/表格/列表 + hover 复制按钮) - 全局 AI 助手:AiAssistantWidget 浮动按钮 + Sheet 侧抽屉 + usePathname 路由推断上下文(7 类场景系统提示)+ dashboard layout 全局注入 AiClientProvider - 内容安全:content-safety.ts 多层过滤(输入/输出安全过滤 + 每日限制 student 50/teacher 200/parent 30/admin 500 + 学生苏格拉底模式),COPPA/FERPA K12 合规 - 多角色 AI 覆盖:家长端 AiChildSummary(学情摘要)+ 管理员端 AiUsageDashboard(使用监控)+ 学生端 AiStudyPath(个性化学习路径) - i18n 修复:8 处错误键引用 + zh-CN/en ai.json 全面扩展 - 架构文档 004/005 同步更新
This commit is contained in:
190
src/modules/ai/hooks/use-ai-chat-stream.ts
Normal file
190
src/modules/ai/hooks/use-ai-chat-stream.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { AiChatMessage } from "../types"
|
||||
|
||||
/**
|
||||
* AI 流式聊天 Hook
|
||||
*
|
||||
* 通过 SSE 端点消费流式 AI 回复。
|
||||
* 支持:
|
||||
* - 逐 token 渲染
|
||||
* - 停止生成(AbortController)
|
||||
* - 错误处理
|
||||
*/
|
||||
|
||||
type StreamState = {
|
||||
messages: AiChatMessage[]
|
||||
streaming: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type UseAiChatStreamReturn = StreamState & {
|
||||
send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void>
|
||||
stop: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function useAiChatStream(): UseAiChatStreamReturn {
|
||||
const t = useTranslations("ai")
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([])
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const send = useCallback(
|
||||
async (
|
||||
inputMessages: AiChatMessage[],
|
||||
options?: { systemPrompt?: string; providerId?: string }
|
||||
): Promise<void> => {
|
||||
if (streaming) return
|
||||
setStreaming(true)
|
||||
setError(null)
|
||||
|
||||
const userMessage = inputMessages[inputMessages.length - 1]
|
||||
if (userMessage && userMessage.role === "user") {
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
}
|
||||
|
||||
// 添加空的 assistant 消息,用于流式更新
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "" }])
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: inputMessages,
|
||||
systemPrompt: options?.systemPrompt,
|
||||
providerId: options?.providerId,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = t("error.chatFailed")
|
||||
try {
|
||||
const errorData = JSON.parse(errorText) as { message?: string }
|
||||
errorMessage = errorData.message ?? errorMessage
|
||||
} catch {
|
||||
// 使用默认错误消息
|
||||
}
|
||||
if (response.status === 429) {
|
||||
errorMessage = t("safety.dailyLimit")
|
||||
} else if (response.status === 403) {
|
||||
errorMessage = t("error.unauthorized")
|
||||
} else if (response.status === 400) {
|
||||
errorMessage = t("safety.blocked")
|
||||
}
|
||||
setError(errorMessage)
|
||||
// 移除空的 assistant 消息
|
||||
setMessages((prev) => {
|
||||
const filtered = [...prev]
|
||||
const last = filtered[filtered.length - 1]
|
||||
if (last && last.role === "assistant" && last.content === "") {
|
||||
filtered.pop()
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
setError(t("error.chatFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = line.slice(6).trim()
|
||||
if (data === "[DONE]") continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
type: "token" | "error" | "filtered"
|
||||
content?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
if (parsed.type === "token" && parsed.content) {
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const last = updated[updated.length - 1]
|
||||
if (last && last.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
content: last.content + parsed.content,
|
||||
}
|
||||
}
|
||||
return updated
|
||||
})
|
||||
} else if (parsed.type === "error") {
|
||||
setError(parsed.message ?? t("error.chatFailed"))
|
||||
setMessages((prev) => {
|
||||
const filtered = [...prev]
|
||||
const last = filtered[filtered.length - 1]
|
||||
if (last && last.role === "assistant" && last.content === "") {
|
||||
filtered.pop()
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
} else if (parsed.type === "filtered") {
|
||||
setError(t("safety.contentFiltered"))
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// 用户主动停止,不显示错误
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
} finally {
|
||||
setStreaming(false)
|
||||
abortControllerRef.current = null
|
||||
// 清理空的 assistant 消息
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (last && last.role === "assistant" && last.content === "") {
|
||||
return prev.slice(0, -1)
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
},
|
||||
[streaming, t]
|
||||
)
|
||||
|
||||
const stop = useCallback((): void => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clear = useCallback((): void => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return { messages, streaming, error, send, stop, clear }
|
||||
}
|
||||
Reference in New Issue
Block a user