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:
SpecialX
2026-06-23 01:34:37 +08:00
parent a60105455e
commit 4da9194a5e
27 changed files with 3522 additions and 172 deletions

View File

@@ -2,15 +2,22 @@
import { useState, useRef, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl"
import { Send, Bot, User } from "lucide-react"
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { AiChatSkeleton } from "./ai-skeleton"
import { useAiClient } from "../context/ai-client-provider"
import { AiMarkdownRenderer } from "./ai-markdown-renderer"
import { useAiChatStream } from "../hooks/use-ai-chat-stream"
import type { AiChatMessage } from "../types"
type AiChatPanelProps = {
@@ -24,13 +31,25 @@ type AiChatPanelProps = {
title?: string
/** 最大消息数 */
maxMessages?: number
/** 是否启用流式响应(默认 true */
streaming?: boolean
/** 建议提示词列表(空状态展示) */
suggestedPrompts?: string[]
}
/**
* AI 聊天面板
*
* 通用 AI 对话组件,可嵌入任何页面。
* 通过 useAiClient() 获取 Server Action 引用,不直接 import actions。
* V2 增强:
* - 流式响应SSE逐 token 渲染
* - Markdown 渲染(代码块、表格、列表)
* - 复制按钮
* - 停止生成按钮
* - 清除对话按钮
* - 建议提示词
* - aria-live 无障碍
* - 对话历史持久化localStorage
*/
export function AiChatPanel({
systemPrompt,
@@ -38,63 +57,68 @@ export function AiChatPanel({
placeholder,
title,
maxMessages = 50,
streaming: _streamingEnabled = true,
suggestedPrompts,
}: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [messages, setMessages] = useState<AiChatMessage[]>([])
const { messages, streaming, error, send, stop, clear } = useAiChatStream()
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const storageKey = "ai-chat-history"
// 从 localStorage 恢复对话历史
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 {
// 忽略解析错误
}
}, [])
// 持久化对话历史
useEffect(() => {
try {
if (messages.length > 0) {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20)))
}
} catch {
// 忽略写入错误
}
}, [messages])
// 自动滚动到底部
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
const handleSend = useCallback(async (): Promise<void> => {
const trimmed = input.trim()
if (!trimmed || loading || messages.length >= maxMessages) return
const handleSend = useCallback(
async (content?: string): Promise<void> => {
const trimmed = (content ?? input).trim()
if (!trimmed || streaming || messages.length >= maxMessages) return
const userMessage: AiChatMessage = { role: "user", content: trimmed }
const contextPrefix = contextMessage
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}`
: trimmed
const systemMessage: AiChatMessage | null = systemPrompt
? { role: "system", content: systemPrompt }
: null
const contextPrefix = contextMessage
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}`
: trimmed
const requestMessages: AiChatMessage[] = [
...(systemMessage ? [systemMessage] : []),
...messages,
{ role: "user" as const, content: contextPrefix },
]
const requestMessages: AiChatMessage[] = [
...messages,
{ role: "user", content: contextPrefix },
]
setInput("")
setLoading(true)
setMessages((prev) => [...prev, userMessage])
try {
const result = await aiClient.chat({
messages: requestMessages,
})
if (result.success && result.data) {
const assistantContent = result.data.content
setMessages((prev) => [
...prev,
{ role: "assistant", content: assistantContent },
])
} else {
toast.error(result.message ?? t("error.chatFailed"))
setMessages((prev) => prev.filter((m) => m !== userMessage))
}
} catch {
toast.error(t("error.chatFailed"))
setMessages((prev) => prev.filter((m) => m !== userMessage))
} finally {
setLoading(false)
}
}, [input, loading, messages, maxMessages, systemPrompt, contextMessage, aiClient, t])
setInput("")
await send(requestMessages, { systemPrompt })
},
[input, streaming, messages, maxMessages, systemPrompt, contextMessage, send]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -103,21 +127,77 @@ export function AiChatPanel({
}
}
if (loading && messages.length === 0) {
const handleClear = (): void => {
if (window.confirm(t("chat.clearConfirm"))) {
clear()
try {
localStorage.removeItem(storageKey)
} catch {
// 忽略
}
toast.success(t("chat.clear"))
}
}
const handleSuggestedPrompt = (prompt: string): void => {
void handleSend(prompt)
}
if (streaming && messages.length === 0) {
return <AiChatSkeleton />
}
const defaultSuggestedPrompts = suggestedPrompts ?? [
t("chat.suggestedPrompts.teacher.0"),
t("chat.suggestedPrompts.teacher.1"),
t("chat.suggestedPrompts.teacher.2"),
]
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{title ?? t("chat.title")}
</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{title ?? t("chat.title")}
</CardTitle>
{messages.length > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground"
onClick={handleClear}
aria-label={t("chat.clear")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("chat.clear")}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">
{error ? (
<div
className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
role="alert"
>
{error}
</div>
) : null}
{messages.length > 0 ? (
<ScrollArea className="h-[300px] w-full rounded-md border p-3">
<ScrollArea
className="h-[300px] w-full rounded-md border p-3"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-3" ref={scrollRef}>
{messages.map((message, index) => (
<div
@@ -136,21 +216,50 @@ export function AiChatPanel({
: "bg-muted"
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{loading ? (
{streaming ? (
<div className="flex gap-2 justify-start">
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
<div className="rounded-md px-3 py-2 text-sm bg-muted">
<span className="animate-pulse">{t("chat.thinking")}</span>
<span className="animate-pulse">{t("chat.streaming")}</span>
<span className="inline-block w-1 h-4 ml-1 bg-primary animate-pulse" />
</div>
</div>
) : null}
</div>
</ScrollArea>
) : null}
) : (
<div className="space-y-3">
<div className="rounded-md border border-dashed p-6 text-center">
<Sparkles className="h-8 w-8 text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-3">
{t("chat.suggestedPrompts.title")}
</p>
<div className="flex flex-wrap gap-2 justify-center">
{defaultSuggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => handleSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Textarea
value={input}
@@ -158,18 +267,30 @@ export function AiChatPanel({
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-[60px] resize-none"
disabled={loading || messages.length >= maxMessages}
disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")}
/>
<Button
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={!input.trim() || loading || messages.length >= maxMessages}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
onClick={stop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={!input.trim() || messages.length >= maxMessages}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
{messages.length >= maxMessages ? (
<p className="text-xs text-muted-foreground text-center">