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:
@@ -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")}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user