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:
SpecialX
2026-06-23 09:39:18 +08:00
parent 036a2f2839
commit 696346dc08
22 changed files with 847 additions and 238 deletions

View File

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