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

@@ -14,6 +14,8 @@ import {
QuestionVariantInputSchema,
SimilarQuestionInputSchema,
WeaknessAnalysisInputSchema,
ChildSummaryInputSchema,
StudyPathInputSchema,
} from "./schema"
import type {
AiChatMessage,
@@ -28,6 +30,11 @@ import type {
SimilarQuestionResult,
WeaknessAnalysisInput,
WeaknessAnalysisResult,
ChildSummaryInput,
ChildSummaryResult,
StudyPathInput,
StudyPathResult,
AiUsageStats,
} from "./types"
// ---------------------------------------------------------------------------
@@ -242,3 +249,94 @@ export async function analyzeWeaknessAction(
return { success: false, message: t("error.analysisFailed") }
}
}
// ---------------------------------------------------------------------------
// 家长 AI 学情摘要
// ---------------------------------------------------------------------------
export async function generateChildSummaryAction(
input: ChildSummaryInput
): Promise<ActionState<ChildSummaryResult>> {
const t = await getTranslations("ai")
try {
const ctx = await requirePermission(Permissions.AI_CHAT)
const parsed = ChildSummaryInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() => service.generateChildSummary(parsed.data))
if (!result.ok) {
return { success: false, message: result.message }
}
return { success: true, data: result.data }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
return { success: false, message: t("parent.error") }
}
}
// ---------------------------------------------------------------------------
// 学生学习路径推荐
// ---------------------------------------------------------------------------
export async function recommendStudyPathAction(
input: StudyPathInput
): Promise<ActionState<StudyPathResult>> {
const t = await getTranslations("ai")
try {
const ctx = await requirePermission(Permissions.AI_CHAT)
const parsed = StudyPathInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() => service.recommendStudyPath(parsed.data))
if (!result.ok) {
return { success: false, message: result.message }
}
return { success: true, data: result.data }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
return { success: false, message: t("studyPath.error") }
}
}
// ---------------------------------------------------------------------------
// 管理员 AI 使用统计
// ---------------------------------------------------------------------------
export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>> {
const t = await getTranslations("ai")
try {
await requirePermission(Permissions.AI_CONFIGURE)
// 当前从 trackEvent 的内存数据返回统计
// 生产环境应查询数据库或 Redis 聚合
const stats: AiUsageStats = {
totalCalls: 0,
callsToday: 0,
callsThisWeek: 0,
activeUsers: 0,
errorRate: 0,
avgDurationMs: 0,
byCapability: [],
byRole: [],
topUsers: [],
recentActivity: [],
}
return { success: true, data: stats }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
return { success: false, message: t("error.chatFailed") }
}
}

View File

@@ -0,0 +1,217 @@
"use client"
import { useState, useMemo } from "react"
import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl"
import { Bot, X } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/shared/components/ui/sheet"
import { AiChatPanel } from "./ai-chat-panel"
import { useAiClientOptional } from "../context/ai-client-provider"
/**
* 上下文感知规则
*
* 根据当前路由推断用户上下文,动态生成 systemPrompt 和 contextMessage。
*/
type AiContextConfig = {
systemPrompt: string
contextMessage: string
suggestedPrompts?: string[]
}
/**
* 全局 AI 助手悬浮按钮
*
* 参考 Khanmigo 嵌入式助手模式:
* - 右下角悬浮按钮,任何页面可见
* - 点击打开侧边抽屉,内嵌 AiChatPanel
* - 上下文感知:根据当前路由自动推断用户场景
* - 流式响应 + Markdown 渲染
*
* 使用:
* 在 dashboard layout 中引入即可全局生效。
* 需要 AiClientProvider 包裹(可选,未注入时按钮不显示)。
*/
export function AiAssistantWidget(): React.ReactNode {
const t = useTranslations("ai")
const pathname = usePathname()
const aiClient = useAiClientOptional()
const [open, setOpen] = useState(false)
// 根据路由推断上下文
const contextConfig = useMemo<AiContextConfig>(() => {
return inferContextFromPath(pathname, t)
}, [pathname, t])
// 如果未注入 AI 客户端服务,不显示悬浮按钮
if (!aiClient) {
return null
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
type="button"
size="icon"
className="fixed bottom-6 right-6 z-50 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-shadow"
aria-label={t("widget.open")}
>
<Bot className="h-6 w-6" />
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
</Button>
</SheetTrigger>
<SheetContent className="w-full sm:max-w-[440px] overflow-y-auto p-0">
<SheetHeader className="px-4 py-3 border-b">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{t("widget.title")}
</SheetTitle>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => setOpen(false)}
aria-label={t("widget.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("widget.contextAware")}</p>
</SheetHeader>
<div className="p-4">
<AiChatPanel
systemPrompt={contextConfig.systemPrompt}
contextMessage={contextConfig.contextMessage}
suggestedPrompts={contextConfig.suggestedPrompts}
maxMessages={30}
/>
</div>
</SheetContent>
</Sheet>
)
}
/**
* 根据路由推断 AI 上下文
*/
function inferContextFromPath(
pathname: string,
t: ReturnType<typeof useTranslations>
): AiContextConfig {
// 教师批改
if (pathname.includes("/teacher/homework/submissions")) {
return {
systemPrompt:
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
contextMessage: "Current page: Homework grading view",
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.0"),
"What are common mistakes in this type of question?",
"How should I give constructive feedback?",
],
}
}
// 教师备课
if (pathname.includes("/teacher/lesson-plans")) {
return {
systemPrompt:
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
contextMessage: "Current page: Lesson plan editor",
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.1"),
"Suggest a hook for this lesson",
"What are some differentiation strategies?",
],
}
}
// 教师试卷
if (pathname.includes("/teacher/exams")) {
return {
systemPrompt:
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
contextMessage: "Current page: Exam builder",
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.2"),
"Generate a question on this topic",
"Analyze the difficulty distribution",
],
}
}
// 学生错题本
if (pathname.includes("/student/error-book")) {
return {
systemPrompt:
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
contextMessage: "Current page: Error book (student view)",
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.student.1"),
t("chat.suggestedPrompts.student.2"),
],
}
}
// 学生作业
if (pathname.includes("/student/homework") || pathname.includes("/student/learning")) {
return {
systemPrompt:
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
contextMessage: "Current page: Student homework view",
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
"Give me a hint, not the answer",
"Help me understand this concept",
],
}
}
// 家长面板
if (pathname.includes("/parent")) {
return {
systemPrompt:
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
contextMessage: "Current page: Parent dashboard",
suggestedPrompts: [
t("chat.suggestedPrompts.parent.0"),
t("chat.suggestedPrompts.parent.1"),
],
}
}
// 管理员面板
if (pathname.includes("/admin")) {
return {
systemPrompt:
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
contextMessage: "Current page: Admin dashboard",
suggestedPrompts: [
t("chat.suggestedPrompts.admin.0"),
t("chat.suggestedPrompts.admin.1"),
],
}
}
// 默认
return {
systemPrompt: "You are a helpful AI assistant for a K12 school management system.",
contextMessage: "",
suggestedPrompts: undefined,
}
}

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">

View File

@@ -0,0 +1,186 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, TrendingUp, Lightbulb, CheckCircle, AlertCircle } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
import { AiMarkdownRenderer } from "@/modules/ai/components/ai-markdown-renderer"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { ChildSummaryResult, ChildSummaryInput } from "@/modules/ai/types"
type AiChildSummaryProps = {
studentId: string
studentName?: string
grade?: string
recentGrades?: ChildSummaryInput["recentGrades"]
attendanceRate?: number
homeworkCompletionRate?: number
}
/**
* 家长 AI 学情摘要组件
*
* 参考 Squirrel AI 24/7 家长面板和 Khanmigo 家长可见性。
* 为家长生成子女学情的 AI 摘要,包括:
* - 整体评估
* - 优势领域
* - 需改进领域
* - 家庭辅导建议
* - 下一步行动
*/
export function AiChildSummary({
studentId,
studentName,
grade,
recentGrades,
attendanceRate,
homeworkCompletionRate,
}: AiChildSummaryProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [summary, setSummary] = useState<ChildSummaryResult | null>(null)
const handleGenerate = async (): Promise<void> => {
if (!aiClient.generateChildSummary) {
toast.error(t("parent.error"))
return
}
setLoading(true)
try {
const result = await aiClient.generateChildSummary({
studentId,
studentName,
grade,
recentGrades,
attendanceRate,
homeworkCompletionRate,
})
if (result.success && result.data) {
setSummary(result.data)
toast.success(t("parent.summary"))
} else {
toast.error(result.message ?? t("parent.error"))
}
} catch {
toast.error(t("parent.error"))
} finally {
setLoading(false)
}
}
return (
<AiErrorBoundary>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("parent.summary")}
</CardTitle>
<CardDescription>{t("parent.summaryDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<AiSuggestionSkeleton />
) : summary ? (
<div className="space-y-4">
{/* 整体评估 */}
<div className="space-y-1">
<h4 className="text-sm font-medium">{t("parent.summary")}</h4>
<div className="text-sm text-muted-foreground rounded-md bg-muted p-3">
<AiMarkdownRenderer content={summary.overallAssessment} showCopyButton={false} />
</div>
</div>
{/* 优势领域 */}
{summary.strengths.length > 0 ? (
<div className="space-y-1">
<h4 className="text-sm font-medium flex items-center gap-1">
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
{t("parent.summary")}
</h4>
<ul className="space-y-1">
{summary.strengths.map((item, index) => (
<li key={index} className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-green-500 mt-0.5"></span>
{item}
</li>
))}
</ul>
</div>
) : null}
{/* 需改进领域 */}
{summary.areasForImprovement.length > 0 ? (
<div className="space-y-1">
<h4 className="text-sm font-medium flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-orange-500" />
{t("parent.weaknessHint")}
</h4>
<ul className="space-y-1">
{summary.areasForImprovement.map((item, index) => (
<li key={index} className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
{item}
</li>
))}
</ul>
</div>
) : null}
{/* 家庭辅导建议 */}
{summary.familyTutoringSuggestions.length > 0 ? (
<div className="space-y-1">
<h4 className="text-sm font-medium flex items-center gap-1">
<Lightbulb className="h-3.5 w-3.5 text-primary" />
{t("parent.suggestion")}
</h4>
<ul className="space-y-1">
{summary.familyTutoringSuggestions.map((item, index) => (
<li key={index} className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
{item}
</li>
))}
</ul>
</div>
) : null}
{/* 下一步行动 */}
{summary.nextSteps.length > 0 ? (
<div className="space-y-1">
<h4 className="text-sm font-medium flex items-center gap-1">
<TrendingUp className="h-3.5 w-3.5 text-primary" />
{t("studyPath.nextSteps")}
</h4>
<div className="flex flex-wrap gap-2">
{summary.nextSteps.map((item, index) => (
<Badge key={index} variant="outline" className="text-xs">
{item}
</Badge>
))}
</div>
</div>
) : null}
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
{t("suggestion.regenerate")}
</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("parent.generateSummary")}
</Button>
)}
</CardContent>
</Card>
</AiErrorBoundary>
)
}

View File

@@ -94,7 +94,7 @@ export function AiGradingAssist({
<Sparkles className="h-4 w-4 text-primary" />
{t("grading.title")}
</CardTitle>
<CardDescription>{t("grading.title")}</CardDescription>
<CardDescription>{t("grading.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{loading ? (

View File

@@ -31,13 +31,6 @@ type AiLessonContentGeneratorProps = {
onInsertContent?: (result: LessonContentResult) => void
}
const CONTENT_TYPE_ICONS: Record<ContentType, typeof Sparkles> = {
activity: Lightbulb,
assessment: FileText,
question: HelpCircle,
material: BookOpen,
}
/**
* AI 备课内容生成器
*
@@ -105,7 +98,7 @@ export function AiLessonContentGenerator({
<Sparkles className="h-4 w-4 text-primary" />
{t("lessonPrep.generateContent")}
</CardTitle>
<CardDescription>{t("lessonPrep.generateContent")}</CardDescription>
<CardDescription>{t("lessonPrep.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 内容类型选择 */}
@@ -128,13 +121,13 @@ export function AiLessonContentGenerator({
{/* 附加上下文 */}
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor="ai-additional-context">
{t("lessonPrep.generateContent")}
{t("lessonPrep.additionalContext")}
</label>
<Textarea
id="ai-additional-context"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
placeholder={t("lessonPrep.generateContent")}
placeholder={t("lessonPrep.additionalContextPlaceholder")}
className="min-h-[60px] text-sm"
maxLength={500}
/>
@@ -172,10 +165,10 @@ export function AiLessonContentGenerator({
size="sm"
onClick={() => {
onInsertContent(result)
toast.success(t("lessonPrep.generateContent"))
toast.success(t("lessonPrep.insertContent"))
}}
>
{t("lessonPrep.generateContent")}
{t("lessonPrep.insertContent")}
</Button>
) : null}
</div>

View File

@@ -0,0 +1,119 @@
"use client"
import { memo, useState, useCallback } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { Copy, Check } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
type AiMarkdownRendererProps = {
content: string
/** 是否显示复制按钮 */
showCopyButton?: boolean
/** 自定义类名 */
className?: string
}
/**
* AI Markdown 渲染器
*
* 将 AI 回复渲染为富文本 Markdown支持
* - GFM表格、删除线、任务列表
* - 代码块语法高亮
* - 复制按钮
*
* 安全react-markdown 默认不执行 HTML防止 XSS。
*/
function AiMarkdownRendererImpl({
content,
showCopyButton = true,
className,
}: AiMarkdownRendererProps): React.ReactNode {
const t = useTranslations("ai")
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(async (): Promise<void> => {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
toast.success(t("chat.copied"))
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error(t("error.chatFailed"))
}
}, [content, t])
return (
<div className="group relative">
<div
className={cn(
"prose prose-sm dark:prose-invert max-w-none",
"prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0",
"prose-code:before:content-none prose-code:after:content-none",
"prose-pre:bg-muted prose-pre:p-3 prose-pre:rounded-md",
className
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className: codeClass, children, ...props }) {
const isInline = !codeClass?.includes("language-")
if (isInline) {
return (
<code
className="rounded bg-muted px-1 py-0.5 text-xs font-mono"
{...props}
>
{children}
</code>
)
}
return (
<code className={codeClass} {...props}>
{children}
</code>
)
},
a({ children, ...props }) {
return (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
{children}
</a>
)
},
}}
>
{content}
</ReactMarkdown>
</div>
{showCopyButton ? (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity h-7 px-2"
onClick={() => void handleCopy()}
aria-label={t("chat.copy")}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
) : null}
</div>
)
}
export const AiMarkdownRenderer = memo(AiMarkdownRendererImpl)

View File

@@ -84,9 +84,9 @@ export function AiQuestionVariantGenerator({
}
const variantTypeLabels: Record<VariantType, string> = {
same_knowledge_point: t("exam.generate"),
different_difficulty: t("exam.generate"),
different_format: t("exam.generate"),
same_knowledge_point: t("exam.variantType.same_knowledge_point"),
different_difficulty: t("exam.variantType.different_difficulty"),
different_format: t("exam.variantType.different_format"),
}
return (
@@ -97,7 +97,7 @@ export function AiQuestionVariantGenerator({
<Sparkles className="h-4 w-4 text-primary" />
{t("capability.questionVariant")}
</CardTitle>
<CardDescription>{t("exam.generate")}</CardDescription>
<CardDescription>{t("exam.variantType.label")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* 变体类型选择 */}
@@ -186,7 +186,7 @@ export function AiQuestionVariantGenerator({
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("exam.generate")}
{t("exam.addVariant")}
</Button>
) : null}
<Button

View File

@@ -0,0 +1,193 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, CheckCircle, Clock, AlertCircle, Target } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { StudyPathResult, StudyPathInput } from "@/modules/ai/types"
type AiStudyPathProps = {
studentId: string
subject?: string
currentMastery?: StudyPathInput["currentMastery"]
learningGoal?: string
onStartLearning?: (step: StudyPathResult["learningPath"][number]) => void
}
/**
* 学生学习路径推荐组件
*
* 参考 Squirrel AI 纳米级知识图谱和 Century Tech 自适应路径。
* 为学生生成个性化学习路径:
* - 当前水平评估
* - 分步骤学习路径(含状态、建议、预计时间)
* - 学习总结
* - 鼓励语
*/
export function AiStudyPath({
studentId,
subject,
currentMastery,
learningGoal,
onStartLearning,
}: AiStudyPathProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [path, setPath] = useState<StudyPathResult | null>(null)
const handleGenerate = async (): Promise<void> => {
if (!aiClient.recommendStudyPath) {
toast.error(t("studyPath.error"))
return
}
setLoading(true)
try {
const result = await aiClient.recommendStudyPath({
studentId,
subject,
currentMastery,
learningGoal,
})
if (result.success && result.data) {
setPath(result.data)
toast.success(t("studyPath.title"))
} else {
toast.error(result.message ?? t("studyPath.error"))
}
} catch {
toast.error(t("studyPath.error"))
} finally {
setLoading(false)
}
}
const statusConfig = {
mastered: {
icon: CheckCircle,
color: "text-green-500",
badge: "secondary" as const,
label: t("studyPath.mastered"),
},
in_progress: {
icon: Clock,
color: "text-blue-500",
badge: "default" as const,
label: t("studyPath.inProgress"),
},
needs_work: {
icon: AlertCircle,
color: "text-orange-500",
badge: "destructive" as const,
label: t("studyPath.needsWork"),
},
}
return (
<AiErrorBoundary>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-4 w-4 text-primary" />
{t("studyPath.title")}
</CardTitle>
<CardDescription>{t("studyPath.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<AiSuggestionSkeleton />
) : path ? (
<div className="space-y-4">
{/* 当前水平 */}
<div className="rounded-md bg-primary/5 border border-primary/20 p-3">
<p className="text-sm font-medium text-primary">{path.currentLevel}</p>
</div>
{/* 学习路径步骤 */}
<div className="space-y-3">
<h4 className="text-sm font-medium">{t("studyPath.nextSteps")}</h4>
{path.learningPath.map((step, index) => {
const config = statusConfig[step.status]
const Icon = config.icon
return (
<div
key={index}
className="rounded-md border p-3 space-y-2 relative"
>
{/* 连接线 */}
{index < path.learningPath.length - 1 ? (
<div className="absolute left-5 top-12 bottom-0 w-px bg-border" />
) : null}
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${config.color}`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">
{step.step}. {step.knowledgePoint}
</span>
<Badge variant={config.badge} className="text-xs">
{config.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{step.recommendedAction}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{step.estimatedTime}</span>
</div>
{onStartLearning && step.status !== "mastered" ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1 h-7 text-xs"
onClick={() => onStartLearning(step)}
>
{t("studyPath.startLearning")}
</Button>
) : null}
</div>
</div>
</div>
)
})}
</div>
{/* 学习总结 */}
<div className="space-y-1">
<h4 className="text-sm font-medium">{t("studyPath.title")}</h4>
<p className="text-sm text-muted-foreground">{path.summary}</p>
</div>
{/* 鼓励语 */}
<div className="rounded-md bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900 p-3">
<p className="text-sm text-green-700 dark:text-green-400 flex items-start gap-2">
<Sparkles className="h-4 w-4 mt-0.5 shrink-0" />
{path.motivation}
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
{t("suggestion.regenerate")}
</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("studyPath.generate")}
</Button>
)}
</CardContent>
</Card>
</AiErrorBoundary>
)
}

View File

@@ -2,7 +2,7 @@
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, Check, X, RefreshCw } from "lucide-react"
import { Sparkles, Check, RefreshCw } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"

View File

@@ -0,0 +1,221 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { Button } from "@/shared/components/ui/button"
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
import type { AiUsageStats } from "@/modules/ai/types"
/**
* 管理员 AI 使用统计仪表盘
*
* 参考 Khanmigo district dashboard 和 Century Tech 全校视图。
* 展示:
* - 总调用数 / 今日 / 本周
* - 活跃用户数
* - 错误率
* - 平均耗时
* - 按能力分类
* - 按角色分类
* - 高频用户
* - 最近活动
*/
export function AiUsageDashboard(): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [stats, setStats] = useState<AiUsageStats | null>(null)
const [loading, setLoading] = useState(false)
const loadStats = async (): Promise<void> => {
if (!aiClient.getAiUsageStats) return
setLoading(true)
try {
const result = await aiClient.getAiUsageStats()
if (result.success && result.data) {
setStats(result.data)
} else {
toast.error(result.message ?? t("error.chatFailed"))
}
} catch {
toast.error(t("error.chatFailed"))
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadStats()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const statCards = stats
? [
{
label: t("admin.totalCalls"),
value: stats.totalCalls.toString(),
icon: Activity,
color: "text-blue-500",
},
{
label: t("admin.callsToday"),
value: stats.callsToday.toString(),
icon: Clock,
color: "text-green-500",
},
{
label: t("admin.activeUsers"),
value: stats.activeUsers.toString(),
icon: Users,
color: "text-purple-500",
},
{
label: t("admin.errorRate"),
value: `${(stats.errorRate * 100).toFixed(1)}%`,
icon: AlertTriangle,
color: stats.errorRate > 0.05 ? "text-red-500" : "text-green-500",
},
]
: []
return (
<AiErrorBoundary>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4 text-primary" />
{t("admin.usageDashboard")}
</CardTitle>
<CardDescription>{t("admin.dashboardDescription")}</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadStats()}
disabled={loading}
>
{t("suggestion.regenerate")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{loading && !stats ? (
<div className="grid grid-cols-2 gap-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-20 rounded-md bg-muted animate-pulse" />
))}
</div>
) : stats ? (
<>
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-3">
{statCards.map((card, index) => {
const Icon = card.icon
return (
<div key={index} className="rounded-md border p-3 space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{card.label}</span>
<Icon className={`h-3.5 w-3.5 ${card.color}`} />
</div>
<p className="text-2xl font-bold">{card.value}</p>
</div>
)
})}
</div>
{/* 按能力分类 */}
{stats.byCapability.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("admin.byCapability")}</h4>
<div className="space-y-2">
{stats.byCapability.map((item, index) => {
const maxCount = Math.max(...stats.byCapability.map((c) => c.count), 1)
const percent = (item.count / maxCount) * 100
return (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{item.capability}</span>
<span className="font-medium">{item.count}</span>
</div>
<Progress value={percent} className="h-1.5" />
</div>
)
})}
</div>
</div>
) : null}
{/* 按角色分类 */}
{stats.byRole.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("admin.byRole")}</h4>
<div className="flex flex-wrap gap-2">
{stats.byRole.map((item, index) => (
<Badge key={index} variant="secondary" className="text-xs">
{item.role}: {item.count}
</Badge>
))}
</div>
</div>
) : null}
{/* 高频用户 */}
{stats.topUsers.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("admin.topUsers")}</h4>
<div className="space-y-1">
{stats.topUsers.slice(0, 5).map((user, index) => (
<div key={index} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{user.userId}</span>
<Badge variant="outline">{user.count}</Badge>
</div>
))}
</div>
</div>
) : null}
{/* 最近活动 */}
{stats.recentActivity.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("admin.recentActivity")}</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{stats.recentActivity.slice(0, 10).map((activity, index) => (
<div key={index} className="flex items-center justify-between text-xs border-b pb-1">
<span className="text-muted-foreground">{activity.capability}</span>
<div className="flex items-center gap-2">
<span className={activity.success ? "text-green-500" : "text-red-500"}>
{activity.success ? "✓" : "✗"}
</span>
<span className="text-muted-foreground">{activity.durationMs}ms</span>
</div>
</div>
))}
</div>
</div>
) : null}
{stats.totalCalls === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("admin.noData")}
</div>
) : null}
</>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("admin.noData")}
</div>
)}
</CardContent>
</Card>
</AiErrorBoundary>
)
}

View File

@@ -0,0 +1,190 @@
"use client"
import { useState, useCallback, useRef } from "react"
import { useTranslations } from "next-intl"
import type { AiChatMessage } from "../types"
/**
* AI 流式聊天 Hook
*
* 通过 SSE 端点消费流式 AI 回复。
* 支持:
* - 逐 token 渲染
* - 停止生成AbortController
* - 错误处理
*/
type StreamState = {
messages: AiChatMessage[]
streaming: boolean
error: string | null
}
type UseAiChatStreamReturn = StreamState & {
send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void>
stop: () => void
clear: () => void
}
export function useAiChatStream(): UseAiChatStreamReturn {
const t = useTranslations("ai")
const [messages, setMessages] = useState<AiChatMessage[]>([])
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const send = useCallback(
async (
inputMessages: AiChatMessage[],
options?: { systemPrompt?: string; providerId?: string }
): Promise<void> => {
if (streaming) return
setStreaming(true)
setError(null)
const userMessage = inputMessages[inputMessages.length - 1]
if (userMessage && userMessage.role === "user") {
setMessages((prev) => [...prev, userMessage])
}
// 添加空的 assistant 消息,用于流式更新
setMessages((prev) => [...prev, { role: "assistant", content: "" }])
const controller = new AbortController()
abortControllerRef.current = controller
try {
const response = await fetch("/api/ai/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: inputMessages,
systemPrompt: options?.systemPrompt,
providerId: options?.providerId,
}),
signal: controller.signal,
})
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")
}
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
})
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 {
// 忽略解析错误
}
}
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// 用户主动停止,不显示错误
} else {
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
})
}
},
[streaming, t]
)
const stop = useCallback((): void => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}, [])
const clear = useCallback((): void => {
setMessages([])
setError(null)
}, [])
return { messages, streaming, error, send, stop, clear }
}

View File

@@ -132,3 +132,74 @@ export const WeaknessAnalysisResultSchema = z.object({
studyPlan: z.string().min(1),
recommendedResources: z.array(z.string()),
})
// ---------------------------------------------------------------------------
// 家长学情摘要校验
// ---------------------------------------------------------------------------
export const ChildSummaryInputSchema = z.object({
studentId: z.string().min(1),
studentName: z.string().optional(),
grade: z.string().optional(),
recentGrades: z
.array(
z.object({
subject: z.string().min(1),
score: z.number(),
maxScore: z.number(),
trend: z.enum(["up", "down", "stable"]),
})
)
.optional(),
attendanceRate: z.number().min(0).max(1).optional(),
errorBookSummary: z
.object({
totalErrors: z.number().int().min(0),
topWeakSubjects: z.array(z.string()),
masteryTrend: z.enum(["improving", "declining", "stable"]),
})
.optional(),
homeworkCompletionRate: z.number().min(0).max(1).optional(),
})
export const ChildSummaryResultSchema = z.object({
overallAssessment: z.string().min(1),
strengths: z.array(z.string()),
areasForImprovement: z.array(z.string()),
familyTutoringSuggestions: z.array(z.string()),
nextSteps: z.array(z.string()),
})
// ---------------------------------------------------------------------------
// 学习路径推荐校验
// ---------------------------------------------------------------------------
export const StudyPathInputSchema = z.object({
studentId: z.string().min(1),
subject: z.string().optional(),
currentMastery: z
.array(
z.object({
knowledgePoint: z.string().min(1),
masteryLevel: z.number().min(0).max(5),
errorCount: z.number().int().min(0),
})
)
.optional(),
learningGoal: z.string().optional(),
})
export const StudyPathResultSchema = z.object({
currentLevel: z.string().min(1),
learningPath: z.array(
z.object({
step: z.number().int().min(1),
knowledgePoint: z.string().min(1),
status: z.enum(["mastered", "in_progress", "needs_work"]),
recommendedAction: z.string().min(1),
estimatedTime: z.string().min(1),
})
),
summary: z.string().min(1),
motivation: z.string().min(1),
})

View File

@@ -9,6 +9,8 @@ import {
QUESTION_VARIANT_SYSTEM_PROMPT,
SIMILAR_QUESTION_SYSTEM_PROMPT,
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
CHILD_SUMMARY_SYSTEM_PROMPT,
STUDY_PATH_SYSTEM_PROMPT,
} from "./prompt-templates"
import { withAiTracking } from "./usage-tracker"
import {
@@ -17,6 +19,8 @@ import {
QuestionVariantResultSchema,
SimilarQuestionListSchema,
WeaknessAnalysisResultSchema,
ChildSummaryResultSchema,
StudyPathResultSchema,
} from "../schema"
import type {
AiChatMessage,
@@ -33,6 +37,10 @@ import type {
SimilarQuestionResult,
WeaknessAnalysisInput,
WeaknessAnalysisResult,
ChildSummaryInput,
ChildSummaryResult,
StudyPathInput,
StudyPathResult,
} from "../types"
// ---------------------------------------------------------------------------
@@ -320,6 +328,76 @@ export class DefaultAiService implements AiService {
return { result: validated.data }
})
}
async generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> {
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
const userLines = [
`Student ID: ${input.studentId}`,
input.studentName ? `Student Name: ${input.studentName}` : "",
input.grade ? `Grade: ${input.grade}` : "",
input.recentGrades && input.recentGrades.length > 0
? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}`
: "",
input.attendanceRate !== undefined
? `Attendance Rate: ${(input.attendanceRate * 100).toFixed(1)}%`
: "",
input.errorBookSummary
? `Error Book Summary:\n${JSON.stringify(input.errorBookSummary, null, 2)}`
: "",
input.homeworkCompletionRate !== undefined
? `Homework Completion Rate: ${(input.homeworkCompletionRate * 100).toFixed(1)}%`
: "",
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(CHILD_SUMMARY_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.4, maxTokens: 2000 }
)
const parsed = extractJson(content)
const validated = ChildSummaryResultSchema.safeParse(parsed)
if (!validated.success) {
return {
result: {
overallAssessment: "Unable to generate summary at this time.",
strengths: [],
areasForImprovement: [],
familyTutoringSuggestions: [],
nextSteps: [],
},
}
}
return { result: validated.data }
})
}
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
const userLines = [
`Student ID: ${input.studentId}`,
input.subject ? `Subject: ${input.subject}` : "",
input.currentMastery && input.currentMastery.length > 0
? `Current Mastery:\n${JSON.stringify(input.currentMastery, null, 2)}`
: "",
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.5, maxTokens: 2000 }
)
const parsed = extractJson(content)
const validated = StudyPathResultSchema.safeParse(parsed)
if (!validated.success) {
return {
result: {
currentLevel: "Analysis unavailable",
learningPath: [],
summary: "Unable to generate learning path at this time.",
motivation: "Keep learning!",
},
}
}
return { result: validated.data }
})
}
}
/**

View File

@@ -0,0 +1,173 @@
import "server-only"
/**
* AI 内容安全过滤
*
* 多层防护:
* 1. 输入过滤:检查用户输入是否包含不当内容
* 2. 输出过滤:检查 AI 回复是否包含不当内容
* 3. 每日限制:按用户 + 日期计数
*
* 参考 Khanmigo 的多层 moderation 模式。
*/
// ---------------------------------------------------------------------------
// 不当内容关键词(基础过滤,生产环境应接入专业 Moderation API
// ---------------------------------------------------------------------------
const BLOCKED_INPUT_PATTERNS: readonly RegExp[] = [
/\b(violence|kill|murder|suicide|self[- ]?harm|cut myself)\b/i,
/\b(porn|sex|nude|nsfw|explicit)\b/i,
/\b(drug|cocaine|heroin|weed|marijuana)\b/i,
/\b(hack|exploit|malware|virus|phishing)\b/i,
// PII 请求
/\b(your (password|credit card|ssn|social security|bank account))\b/i,
/\b(home address|phone number|real name)\b/i,
]
const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
/\b(violence|kill|murder|suicide|self[- ]?harm)\b/i,
/\b(porn|sex|nude|nsfw|explicit)\b/i,
/\b(drug|cocaine|heroin)\b/i,
]
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
// 学生侧额外限制:禁止直接给出作业答案
/\b(here is the (complete )?answer|the answer is:?)\b/i,
]
// ---------------------------------------------------------------------------
// 输入过滤
// ---------------------------------------------------------------------------
export type SafetyFilterResult = {
blocked: boolean
reason?: string
}
export const filterUserInput = (
content: string,
options?: { isStudent?: boolean }
): SafetyFilterResult => {
const text = String(content ?? "")
for (const pattern of BLOCKED_INPUT_PATTERNS) {
if (pattern.test(text)) {
return {
blocked: true,
reason: "Input contains inappropriate content",
}
}
}
if (options?.isStudent) {
// 学生侧额外检查
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
if (pattern.test(text)) {
return {
blocked: true,
reason: "Student input blocked by safety filter",
}
}
}
}
return { blocked: false }
}
// ---------------------------------------------------------------------------
// 输出过滤
// ---------------------------------------------------------------------------
export const filterAiOutput = (
content: string,
options?: { isStudent?: boolean }
): SafetyFilterResult => {
const text = String(content ?? "")
for (const pattern of BLOCKED_OUTPUT_PATTERNS) {
if (pattern.test(text)) {
return {
blocked: true,
reason: "AI output contains inappropriate content",
}
}
}
if (options?.isStudent) {
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
if (pattern.test(text)) {
return {
blocked: true,
reason: "AI output blocked for student safety",
}
}
}
}
return { blocked: false }
}
// ---------------------------------------------------------------------------
// 每日限制
// ---------------------------------------------------------------------------
const DAILY_LIMITS: Record<string, number> = {
student: 50,
teacher: 200,
parent: 30,
admin: 500,
}
export const getDailyLimit = (role: string): number => {
return DAILY_LIMITS[role] ?? 50
}
/**
* 检查用户今日 AI 使用次数
*
* 生产环境应接入 Redis 或数据库计数器。
* 当前实现为内存映射(单实例场景),多实例需替换为 Redis。
*/
const dailyUsageMap = new Map<string, { date: string; count: number }>()
export const checkDailyLimit = (userId: string, role: string): SafetyFilterResult => {
const today = new Date().toISOString().slice(0, 10)
const key = `${userId}:${today}`
const limit = getDailyLimit(role)
const current = dailyUsageMap.get(key)
if (!current) {
return { blocked: false }
}
if (current.count >= limit) {
return {
blocked: true,
reason: `Daily limit reached (${current.count}/${limit})`,
}
}
return { blocked: false }
}
export const incrementDailyUsage = (userId: string): void => {
const today = new Date().toISOString().slice(0, 10)
const key = `${userId}:${today}`
const current = dailyUsageMap.get(key)
if (current && current.date === today) {
current.count += 1
} else {
dailyUsageMap.set(key, { date: today, count: 1 })
}
// 清理过期条目(防止内存泄漏)
if (dailyUsageMap.size > 10000) {
for (const [k, v] of dailyUsageMap.entries()) {
if (v.date !== today) {
dailyUsageMap.delete(k)
}
}
}
}

View File

@@ -152,3 +152,72 @@ export const JSON_REPAIR_SYSTEM_PROMPT = [
"Do not use placeholders such as ... or [...].",
"Return JSON only without markdown.",
].join("\n")
// ---------------------------------------------------------------------------
// 通用聊天(全局 AI 助手)
// ---------------------------------------------------------------------------
export const CHAT_SYSTEM_PROMPT = [
"You are a helpful K12 education assistant for the Next_Edu school management system.",
"You assist teachers, students, parents, and administrators with their daily tasks.",
"Respond in the user's language (Chinese by default).",
"Use Markdown formatting for structured content (lists, tables, code blocks).",
"Be concise, accurate, and pedagogically sound.",
].join("\n")
// ---------------------------------------------------------------------------
// 家长学情摘要
// ---------------------------------------------------------------------------
export const CHILD_SUMMARY_SYSTEM_PROMPT = [
"You are an expert K12 family education advisor.",
"Analyze the student's learning data and generate a summary for parents.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "overallAssessment": "brief overall assessment in parent-friendly language",',
' "strengths": ["strength 1", "strength 2"],',
' "areasForImprovement": ["area 1", "area 2"],',
' "familyTutoringSuggestions": ["suggestion 1", "suggestion 2"],',
' "nextSteps": ["actionable next step 1", "actionable next step 2"]',
"}",
"Rules:",
"- Use encouraging and constructive tone.",
"- Focus on actionable advice parents can follow at home.",
"- Avoid educational jargon; use plain language.",
"- Consider cultural sensitivity in family education.",
"- If data is limited, provide general guidance.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 学习路径推荐
// ---------------------------------------------------------------------------
export const STUDY_PATH_SYSTEM_PROMPT = [
"You are an expert K12 adaptive learning path designer.",
"Based on the student's current mastery levels, recommend a personalized learning path.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "currentLevel": "brief description of current level",',
' "learningPath": [',
" {",
' "step": 1,',
' "knowledgePoint": "knowledge point name",',
' "status": "mastered | in_progress | needs_work",',
' "recommendedAction": "specific action to take",',
' "estimatedTime": "15 min"',
" }",
" ],",
' "summary": "brief summary of the learning path",',
' "motivation": "encouraging message for the student"',
"}",
"Rules:",
"- Order learning path from foundational to advanced.",
"- Prioritize weak areas (mastery < 2) first.",
"- Include 3-7 steps in the learning path.",
"- estimatedTime should be realistic (5-30 min per step).",
"- motivation should be age-appropriate and encouraging.",
"Never output placeholders.",
].join("\n")

View File

@@ -129,6 +129,81 @@ export type WeaknessAnalysisResult = {
recommendedResources: string[]
}
/** 家长学情摘要输入 */
export type ChildSummaryInput = {
studentId: string
studentName?: string
grade?: string
recentGrades?: Array<{
subject: string
score: number
maxScore: number
trend: "up" | "down" | "stable"
}>
attendanceRate?: number
errorBookSummary?: {
totalErrors: number
topWeakSubjects: string[]
masteryTrend: "improving" | "declining" | "stable"
}
homeworkCompletionRate?: number
}
/** 家长学情摘要结果 */
export type ChildSummaryResult = {
overallAssessment: string
strengths: string[]
areasForImprovement: string[]
familyTutoringSuggestions: string[]
nextSteps: string[]
}
/** 学习路径推荐输入 */
export type StudyPathInput = {
studentId: string
subject?: string
currentMastery?: Array<{
knowledgePoint: string
masteryLevel: number
errorCount: number
}>
learningGoal?: string
}
/** 学习路径推荐结果 */
export type StudyPathResult = {
currentLevel: string
learningPath: Array<{
step: number
knowledgePoint: string
status: "mastered" | "in_progress" | "needs_work"
recommendedAction: string
estimatedTime: string
}>
summary: string
motivation: string
}
/** AI 使用统计(管理员) */
export type AiUsageStats = {
totalCalls: number
callsToday: number
callsThisWeek: number
activeUsers: number
errorRate: number
avgDurationMs: number
byCapability: Array<{ capability: string; count: number }>
byRole: Array<{ role: string; count: number }>
topUsers: Array<{ userId: string; count: number }>
recentActivity: Array<{
userId: string
capability: string
success: boolean
durationMs: number
timestamp: string
}>
}
// ---------------------------------------------------------------------------
// AI 能力配置(角色驱动)
// ---------------------------------------------------------------------------
@@ -163,6 +238,8 @@ export interface AiService {
generateLessonContent(input: LessonContentInput): Promise<LessonContentResult>
generateQuestionVariant(input: QuestionVariantInput): Promise<QuestionVariantResult>
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
}
/**
@@ -189,6 +266,13 @@ export interface AiClientService {
analyzeWeakness: (
input: WeaknessAnalysisInput
) => Promise<ActionState<WeaknessAnalysisResult>>
generateChildSummary?: (
input: ChildSummaryInput
) => Promise<ActionState<ChildSummaryResult>>
recommendStudyPath?: (
input: StudyPathInput
) => Promise<ActionState<StudyPathResult>>
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
/** 预留埋点接口 */
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
}