fix(ai): V3 长期问题修复+规则合规+竞品对标
## P1 安全加固 - 原子化每日限额(tryConsumeDailyQuota)解决 TOCTOU 竞态 - 流式端点补齐 Zod 校验 + rate limit + 服务端强制 systemPrompt - 配额回退机制(refundDailyQuota):过滤/失败不扣配额 - PII 最小化:移除 AI prompt 中的学生姓名 ## P1 数据一致性 - 修复 capability 埋点缺失 child_summary/study_path 类型 - 创建 data-access.ts:真实统计聚合替代硬编码零 - 修复 generateChildSummary/recommendStudyPath 的 capability 标记 ## P2 可靠性 - AI 调用重试机制(withRetry 指数退避,429/5xx,2 次重试) - 30s 超时配置 - 流式 controller 安全 enqueue(防已关闭抛错) - localStorage 防抖持久化(500ms,流式过程中跳过) ## P2 TypeScript/规则合规 - 移除 as 断言(VariantType 类型守卫、Permission 类型、StreamErrorKey) - 补齐返回类型标注(POST/getStatusFromError/DashboardLayout) - 拆分 use-ai-chat-stream hook(190→107 行,函数体≤80 行) - 抽取 stream-utils.ts(SSE 解析/错误映射/消息工具) - Tailwind 任意值添加注释说明(max-w-[80%] 聊天气泡) ## P3 竞品对标 - 苏格拉底式辅导强化(对标 Khanmigo): - SOCRATIC_TUTOR_SYSTEM_PROMPT 3 级提示升级 - 强化 STUDENT_BLOCKED_PATTERNS 正则(中英文答案拦截) - validateSocraticOutput 服务端校验(问号结尾+连续陈述句限制) - socratic_warning SSE 事件类型 - 知识图谱集成(对标 Squirrel AI): - StudyPathInput 新增 knowledgeGraph/textbookId 字段 - recommendStudyPathAction 自动从 textbooks 模块获取图谱+掌握度 - STUDY_PATH_SYSTEM_PROMPT 增加前置依赖链规则 - WEAKNESS_ANALYSIS_SYSTEM_PROMPT 增加 rootCause 字段 ## 架构文档同步 - 004 更新 AI 模块章节(V3 标记/新导出/依赖关系/安全机制/文件清单) - 005 更新 modules.ai 节点(dependsOn/exports/dataAccess/streamUtils/dependencyMatrix)
This commit is contained in:
@@ -3,24 +3,24 @@
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { AiChatMessage } from "../types"
|
||||
import {
|
||||
consumeSseStream,
|
||||
getStreamErrorKey,
|
||||
extractErrorMessage,
|
||||
removeTrailingEmptyAssistant,
|
||||
appendTokenToLastAssistant,
|
||||
} from "./stream-utils"
|
||||
|
||||
/**
|
||||
* AI 流式聊天 Hook
|
||||
*
|
||||
* 通过 SSE 端点消费流式 AI 回复。
|
||||
* 支持:
|
||||
* - 逐 token 渲染
|
||||
* - 停止生成(AbortController)
|
||||
* - 错误处理
|
||||
* 支持:逐 token 渲染、停止生成(AbortController)、错误处理。
|
||||
*/
|
||||
|
||||
type StreamState = {
|
||||
type UseAiChatStreamReturn = {
|
||||
messages: AiChatMessage[]
|
||||
streaming: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type UseAiChatStreamReturn = StreamState & {
|
||||
send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void>
|
||||
stop: () => void
|
||||
clear: () => void
|
||||
@@ -46,8 +46,6 @@ export function useAiChatStream(): UseAiChatStreamReturn {
|
||||
if (userMessage && userMessage.role === "user") {
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
}
|
||||
|
||||
// 添加空的 assistant 消息,用于流式更新
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "" }])
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -66,119 +64,42 @@ export function useAiChatStream(): UseAiChatStreamReturn {
|
||||
})
|
||||
|
||||
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")
|
||||
}
|
||||
const fallback = t(getStreamErrorKey(response.status))
|
||||
const errorMessage = await extractErrorMessage(response, fallback)
|
||||
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
|
||||
})
|
||||
setMessages((prev) => removeTrailingEmptyAssistant(prev))
|
||||
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 {
|
||||
// 忽略解析错误
|
||||
}
|
||||
await consumeSseStream(response, (event) => {
|
||||
if (event.type === "token") {
|
||||
setMessages((prev) => appendTokenToLastAssistant(prev, event.content))
|
||||
} else if (event.type === "error") {
|
||||
setError(event.message)
|
||||
setMessages((prev) => removeTrailingEmptyAssistant(prev))
|
||||
} else if (event.type === "filtered") {
|
||||
setError(t("safety.contentFiltered"))
|
||||
} else if (event.type === "socratic_warning") {
|
||||
// 苏格拉底式辅导警告:不阻断,仅提示
|
||||
// 可通过 toast 或 UI 标记展示,此处暂存 error 供组件判断
|
||||
setError(event.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// 用户主动停止,不显示错误
|
||||
} else {
|
||||
if (!(err instanceof DOMException && err.name === "AbortError")) {
|
||||
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
|
||||
})
|
||||
setMessages((prev) => removeTrailingEmptyAssistant(prev))
|
||||
}
|
||||
},
|
||||
[streaming, t]
|
||||
)
|
||||
|
||||
const stop = useCallback((): void => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
abortControllerRef.current?.abort()
|
||||
}, [])
|
||||
|
||||
const clear = useCallback((): void => {
|
||||
|
||||
Reference in New Issue
Block a user