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

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
@@ -31,8 +32,6 @@ type AiChatPanelProps = {
title?: string
/** 最大消息数 */
maxMessages?: number
/** 是否启用流式响应(默认 true */
streaming?: boolean
/** 建议提示词列表(空状态展示) */
suggestedPrompts?: string[]
}
@@ -49,7 +48,7 @@ type AiChatPanelProps = {
* - 清除对话按钮
* - 建议提示词
* - aria-live 无障碍
* - 对话历史持久化localStorage
* - 对话历史持久化localStorage,防抖写入
*/
export function AiChatPanel({
systemPrompt,
@@ -57,7 +56,6 @@ export function AiChatPanel({
placeholder,
title,
maxMessages = 50,
streaming: _streamingEnabled = true,
suggestedPrompts,
}: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai")
@@ -65,33 +63,31 @@ export function AiChatPanel({
const [input, setInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null)
const storageKey = "ai-chat-history"
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 从 localStorage 恢复对话历史
// 持久化对话历史(防抖 500ms避免流式过程中频繁写入
useEffect(() => {
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored) as AiChatMessage[]
if (Array.isArray(parsed) && parsed.length > 0) {
// 通过 send 不合适,直接设置 messages 不支持
// 这里只是恢复显示,不重新发送
}
}
} catch {
// 忽略解析错误
if (messages.length === 0) return
// 流式过程中不写入,流结束后再写
if (streaming) return
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current)
}
}, [])
// 持久化对话历史
useEffect(() => {
try {
if (messages.length > 0) {
persistTimerRef.current = setTimeout(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20)))
} catch {
// 忽略写入错误
}
}, 500)
return () => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current)
}
} catch {
// 忽略写入错误
}
}, [messages])
}, [messages, streaming])
// 自动滚动到底部
useEffect(() => {
@@ -194,7 +190,7 @@ export function AiChatPanel({
{messages.length > 0 ? (
<ScrollArea
className="h-[300px] w-full rounded-md border p-3"
className="h-72 w-full rounded-md border p-3"
aria-live="polite"
aria-relevant="additions text"
>
@@ -202,7 +198,10 @@ export function AiChatPanel({
{messages.map((message, index) => (
<div
key={index}
className={`flex gap-2 ${message.role === "user" ? "justify-end" : "justify-start"}`}
className={cn(
"flex gap-2",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
{message.role === "assistant" ? (
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
@@ -210,11 +209,13 @@ export function AiChatPanel({
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
)}
<div
className={`rounded-md px-3 py-2 text-sm max-w-[80%] ${
className={cn(
// 任意值:聊天气泡需按容器宽度百分比限制,固定 max-w-* 无法适应不同面板宽度
"rounded-md px-3 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
@@ -266,7 +267,7 @@ export function AiChatPanel({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-[60px] resize-none"
className="min-h-16 resize-none"
disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")}
/>