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

@@ -1,6 +1,34 @@
import { AppSidebar } from "@/modules/layout/components/app-sidebar"
import { SidebarProvider } from "@/modules/layout/components/sidebar-provider"
import { SiteHeader } from "@/modules/layout/components/site-header"
import {
AiClientProvider,
} from "@/modules/ai/context/ai-client-provider"
import { AiAssistantWidget } from "@/modules/ai/components/ai-assistant-widget"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
generateChildSummaryAction,
recommendStudyPathAction,
getAiUsageStatsAction,
} from "@/modules/ai/actions"
import type { AiClientService } from "@/modules/ai/types"
const aiClientService: AiClientService = {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
generateChildSummary: generateChildSummaryAction,
recommendStudyPath: recommendStudyPathAction,
getAiUsageStats: getAiUsageStatsAction,
}
export default function DashboardLayout({
children,
@@ -8,14 +36,17 @@ export default function DashboardLayout({
children: React.ReactNode
}) {
return (
<SidebarProvider sidebar={<AppSidebar />}>
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2">
Skip to main content
</a>
<SiteHeader />
<main id="main-content" className="flex-1 overflow-auto p-6">
{children}
</main>
</SidebarProvider>
<AiClientProvider service={aiClientService}>
<SidebarProvider sidebar={<AppSidebar />}>
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2">
Skip to main content
</a>
<SiteHeader />
<main id="main-content" className="flex-1 overflow-auto p-6">
{children}
</main>
<AiAssistantWidget />
</SidebarProvider>
</AiClientProvider>
)
}

View File

@@ -0,0 +1,182 @@
import { NextRequest } from "next/server"
import { auth } from "@/auth"
import { Permissions } from "@/shared/types/permissions"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { createAiChatCompletionStream } from "@/shared/lib/ai/client"
import { getAiErrorMessage } from "@/shared/lib/ai"
import { trackEvent } from "@/shared/lib/track-event"
import { env } from "@/env.mjs"
import { CHAT_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
import {
filterUserInput,
filterAiOutput,
checkDailyLimit,
incrementDailyUsage,
} from "@/modules/ai/services/content-safety"
import type { AiChatMessage } from "@/modules/ai/types"
/**
* AI 聊天流式端点SSE
*
* 使用 Server-Sent Events 逐 token 推送 AI 回复,
* 降低用户感知延迟。
*
* 安全:
* - requirePermission(AI_CHAT) 权限校验
* - 输入/输出内容安全过滤
* - 每日交互限制
* - 学生侧 Socratic 模式
*/
const formatEvent = (data: unknown): string => {
return `data: ${JSON.stringify(data)}\n\n`
}
const formatError = (message: string): string => {
return formatEvent({ type: "error", message })
}
const FORMAT_DONE = "data: [DONE]\n\n"
export async function POST(request: NextRequest): Promise<Response> {
const encoder = new TextEncoder()
try {
// 1. 权限校验
const ctx = await requirePermission(Permissions.AI_CHAT)
const session = await auth()
const userRole = session?.user?.role ?? "student"
const isStudent = userRole === "student"
// 2. 每日限制
const limitCheck = checkDailyLimit(ctx.userId, userRole)
if (limitCheck.blocked) {
return new Response(formatError("Daily limit reached"), {
status: 429,
headers: { "Content-Type": "text/event-stream" },
})
}
// 3. 解析请求
const body = (await request.json()) as {
messages?: AiChatMessage[]
providerId?: string
systemPrompt?: string
}
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
return new Response(formatError("Messages are required"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
})
}
// 4. 输入安全过滤
for (const msg of body.messages) {
if (msg.role === "user") {
const filterResult = filterUserInput(msg.content, { isStudent })
if (filterResult.blocked) {
return new Response(formatError("Input blocked by safety filter"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
})
}
}
}
// 5. 构建 system prompt学生侧 Socratic 模式)
const baseSystemPrompt = body.systemPrompt ?? CHAT_SYSTEM_PROMPT
const studentSystemPrompt = isStudent
? `${baseSystemPrompt}\n\nIMPORTANT: You are in student mode. Use the Socratic method. Do NOT give direct answers. Guide the student to find the answer themselves through questions and hints.`
: baseSystemPrompt
const messages: AiChatMessage[] = [
{ role: "system", content: studentSystemPrompt },
...body.messages,
]
// 6. 流式调用 AI
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const startTime = Date.now()
let fullContent = ""
let success = true
let errorMessage: string | undefined
try {
const aiStream = createAiChatCompletionStream({
messages: messages.map((m) => ({ role: m.role, content: m.content })),
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
temperature: 0.7,
...(body.providerId ? { providerId: body.providerId } : {}),
})
for await (const chunk of aiStream) {
fullContent += chunk
// 输出安全过滤(逐 chunk 检查关键词)
const outputFilter = filterAiOutput(chunk, { isStudent })
if (outputFilter.blocked) {
controller.enqueue(encoder.encode(formatEvent({
type: "filtered",
message: "Content filtered for safety",
})))
success = false
break
}
controller.enqueue(encoder.encode(formatEvent({ type: "token", content: chunk })))
}
// 增加每日使用计数
incrementDailyUsage(ctx.userId)
controller.enqueue(encoder.encode(FORMAT_DONE))
} catch (error) {
success = false
errorMessage = error instanceof Error ? error.message : String(error)
controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error))))
} finally {
controller.close()
// 埋点
void trackEvent({
event: "ai.chat_stream",
userId: ctx.userId,
targetType: "chat",
properties: {
success,
durationMs: Date.now() - startTime,
tokenCount: fullContent.length / 4,
errorMessage,
isStudent,
},
}).catch(() => {
// 静默失败
})
}
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
})
} catch (error) {
if (error instanceof PermissionDeniedError) {
return new Response(formatError("Permission denied"), {
status: 403,
headers: { "Content-Type": "text/event-stream" },
})
}
return new Response(formatError(getAiErrorMessage(error)), {
status: 500,
headers: { "Content-Type": "text/event-stream" },
})
}
}

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
}

View File

@@ -5,8 +5,20 @@
"inputLabel": "Message input",
"send": "Send",
"thinking": "AI is thinking...",
"streaming": "AI is typing...",
"stopGeneration": "Stop generating",
"maxReached": "Maximum messages reached",
"clear": "Clear conversation"
"clear": "Clear conversation",
"clearConfirm": "Clear all messages?",
"copy": "Copy",
"copied": "Copied!",
"suggestedPrompts": {
"title": "Try asking...",
"teacher": ["Help me grade this question", "Generate a classroom activity", "Create a quiz question"],
"student": ["Explain this concept", "Give me a practice question", "Help me study"],
"parent": ["How is my child doing?", "What should I focus on at home?"],
"admin": ["Show AI usage stats", "Which teachers use AI most?"]
}
},
"provider": {
"label": "AI Provider",
@@ -28,10 +40,13 @@
"loaded": "Suggestions loaded",
"selected": "Suggestion selected",
"select": "Select",
"difficulty": "Difficulty"
"difficulty": "Difficulty",
"practiceNow": "Practice Now",
"addAll": "Add All"
},
"grading": {
"title": "AI Grading Suggestion",
"description": "AI-powered scoring and feedback for subjective questions",
"suggestedScore": "Suggested Score",
"confidence": "Confidence",
"feedback": "Feedback",
@@ -40,7 +55,13 @@
"applyFeedback": "Apply Feedback",
"loading": "AI is grading...",
"error": "AI grading failed",
"notAvailable": "AI grading not available for this question type"
"notAvailable": "AI grading not available for this question type",
"batchTitle": "Batch AI Grading",
"batchDescription": "Generate AI suggestions for all subjective questions at once",
"batchGenerate": "Generate All Suggestions",
"batchProgress": "Processing {done}/{total}",
"currentScore": "Current Score",
"scoreDifference": "Difference"
},
"errorBook": {
"similarQuestions": "Similar Questions",
@@ -56,11 +77,18 @@
},
"lessonPrep": {
"generateContent": "Generate Content",
"description": "AI-powered teaching content generation",
"generateActivity": "Suggest Activity",
"generateAssessment": "Generate Assessment",
"generateQuestion": "Generate Discussion Question",
"loading": "Generating...",
"error": "Content generation failed"
"error": "Content generation failed",
"additionalContext": "Additional context",
"additionalContextPlaceholder": "Add any specific requirements or context...",
"insertContent": "Insert Content",
"editBeforeInsert": "Edit before insert",
"history": "Generation History",
"clearHistory": "Clear history"
},
"exam": {
"generate": "Generate",
@@ -81,7 +109,64 @@
"sourceTextPlaceholder": "Paste the full exam text to parse into questions.",
"sourceTextDesc": "AI will extract questions and structure from this text.",
"generationTitle": "AI Generation",
"generationDesc": "Paste the exam text and generate a structured preview."
"generationDesc": "Paste the exam text and generate a structured preview.",
"variantType": {
"label": "Variant type",
"same_knowledge_point": "Same knowledge point, different context",
"different_difficulty": "Different difficulty level",
"different_format": "Different question format"
},
"targetDifficulty": "Target difficulty",
"addVariant": "Add Variant"
},
"parent": {
"summary": "AI Learning Summary",
"summaryDescription": "AI-generated overview of your child's learning progress",
"generateSummary": "Generate Summary",
"weaknessHint": "Areas to focus on",
"suggestion": "Family tutoring suggestion",
"loading": "Generating summary...",
"error": "Failed to generate summary"
},
"admin": {
"usageDashboard": "AI Usage Dashboard",
"dashboardDescription": "Monitor AI usage across the school",
"totalCalls": "Total AI Calls",
"activeUsers": "Active Users",
"costEstimate": "Estimated Cost",
"topUsers": "Top Users",
"byCapability": "By Capability",
"byRole": "By Role",
"recentActivity": "Recent Activity",
"noData": "No AI usage data available",
"callsToday": "Calls today",
"callsThisWeek": "Calls this week",
"errorRate": "Error rate",
"avgDuration": "Avg duration"
},
"studyPath": {
"title": "Your Learning Path",
"description": "AI-personalized learning recommendations",
"nextSteps": "Recommended Next Steps",
"mastered": "Mastered",
"inProgress": "In Progress",
"needsWork": "Needs Work",
"generate": "Generate Learning Path",
"loading": "Generating learning path...",
"error": "Failed to generate learning path",
"startLearning": "Start Learning"
},
"widget": {
"title": "AI Assistant",
"open": "Open AI Assistant",
"close": "Close",
"contextAware": "Context-aware"
},
"safety": {
"blocked": "Your message was blocked by the safety filter. Please keep the conversation educational.",
"dailyLimit": "Daily AI usage limit reached. Please try again tomorrow.",
"studentMode": "AI is in student mode. It will guide you to find the answer.",
"contentFiltered": "Inappropriate content was filtered from the AI response."
},
"error": {
"invalidInput": "Invalid input data",
@@ -104,6 +189,8 @@
"lessonContent": "AI Lesson Content",
"questionVariant": "AI Question Variant",
"similarQuestion": "AI Similar Questions",
"weaknessAnalysis": "AI Weakness Analysis"
"weaknessAnalysis": "AI Weakness Analysis",
"childSummary": "AI Child Summary",
"studyPath": "AI Study Path"
}
}

View File

@@ -5,8 +5,20 @@
"inputLabel": "消息输入",
"send": "发送",
"thinking": "AI 正在思考...",
"streaming": "AI 正在输入...",
"stopGeneration": "停止生成",
"maxReached": "已达到最大消息数",
"clear": "清空对话"
"clear": "清空对话",
"clearConfirm": "确认清空所有消息?",
"copy": "复制",
"copied": "已复制!",
"suggestedPrompts": {
"title": "试试问我...",
"teacher": ["帮我批改这道题", "生成一个课堂活动", "创建一道测验题"],
"student": ["解释这个概念", "给我一道练习题", "帮我复习"],
"parent": ["我孩子学得怎么样?", "在家应该关注什么?"],
"admin": ["显示 AI 使用统计", "哪些老师最常使用 AI"]
}
},
"provider": {
"label": "AI 服务商",
@@ -28,10 +40,13 @@
"loaded": "建议已加载",
"selected": "已选择建议",
"select": "选择",
"difficulty": "难度"
"difficulty": "难度",
"practiceNow": "立即练习",
"addAll": "全部添加"
},
"grading": {
"title": "AI 批改建议",
"description": "AI 驱动的主观题评分与反馈",
"suggestedScore": "建议分数",
"confidence": "置信度",
"feedback": "反馈",
@@ -40,7 +55,13 @@
"applyFeedback": "应用反馈",
"loading": "AI 批改中...",
"error": "AI 批改失败",
"notAvailable": "此题型不支持 AI 批改"
"notAvailable": "此题型不支持 AI 批改",
"batchTitle": "批量 AI 批改",
"batchDescription": "一次性为所有主观题生成 AI 建议",
"batchGenerate": "生成全部建议",
"batchProgress": "处理中 {done}/{total}",
"currentScore": "当前分数",
"scoreDifference": "差值"
},
"errorBook": {
"similarQuestions": "相似题目",
@@ -56,11 +77,18 @@
},
"lessonPrep": {
"generateContent": "生成内容",
"description": "AI 驱动的教学内容生成",
"generateActivity": "建议活动",
"generateAssessment": "生成评估",
"generateQuestion": "生成讨论题",
"loading": "生成中...",
"error": "内容生成失败"
"error": "内容生成失败",
"additionalContext": "附加上下文",
"additionalContextPlaceholder": "添加特定要求或上下文信息...",
"insertContent": "插入内容",
"editBeforeInsert": "插入前编辑",
"history": "生成历史",
"clearHistory": "清空历史"
},
"exam": {
"generate": "生成",
@@ -81,7 +109,64 @@
"sourceTextPlaceholder": "粘贴试卷文本以解析为题目",
"sourceTextDesc": "AI 将从文本中提取题目和结构。",
"generationTitle": "AI 生成",
"generationDesc": "粘贴试卷文本并生成结构化预览。"
"generationDesc": "粘贴试卷文本并生成结构化预览。",
"variantType": {
"label": "变体类型",
"same_knowledge_point": "同知识点,不同情境",
"different_difficulty": "不同难度",
"different_format": "不同题型"
},
"targetDifficulty": "目标难度",
"addVariant": "添加变体"
},
"parent": {
"summary": "AI 学情摘要",
"summaryDescription": "AI 生成的子女学习进度概览",
"generateSummary": "生成摘要",
"weaknessHint": "需关注领域",
"suggestion": "家庭辅导建议",
"loading": "生成摘要中...",
"error": "生成摘要失败"
},
"admin": {
"usageDashboard": "AI 使用仪表盘",
"dashboardDescription": "监控全校 AI 使用情况",
"totalCalls": "AI 调用总数",
"activeUsers": "活跃用户",
"costEstimate": "预估成本",
"topUsers": "高频用户",
"byCapability": "按能力分类",
"byRole": "按角色分类",
"recentActivity": "最近活动",
"noData": "暂无 AI 使用数据",
"callsToday": "今日调用",
"callsThisWeek": "本周调用",
"errorRate": "错误率",
"avgDuration": "平均耗时"
},
"studyPath": {
"title": "你的学习路径",
"description": "AI 个性化学习建议",
"nextSteps": "推荐下一步",
"mastered": "已掌握",
"inProgress": "学习中",
"needsWork": "需要加强",
"generate": "生成学习路径",
"loading": "生成学习路径中...",
"error": "生成学习路径失败",
"startLearning": "开始学习"
},
"widget": {
"title": "AI 助手",
"open": "打开 AI 助手",
"close": "关闭",
"contextAware": "上下文感知"
},
"safety": {
"blocked": "您的消息被安全过滤器拦截,请保持教育性对话。",
"dailyLimit": "今日 AI 使用次数已达上限,请明天再试。",
"studentMode": "AI 处于学生模式,将引导你自主找到答案。",
"contentFiltered": "AI 回复中的不当内容已被过滤。"
},
"error": {
"invalidInput": "输入数据无效",
@@ -104,6 +189,8 @@
"lessonContent": "AI 备课内容",
"questionVariant": "AI 题目变体",
"similarQuestion": "AI 相似题",
"weaknessAnalysis": "AI 薄弱点分析"
"weaknessAnalysis": "AI 薄弱点分析",
"childSummary": "AI 子女摘要",
"studyPath": "AI 学习路径"
}
}

View File

@@ -65,3 +65,31 @@ export const createAiChatCompletion = async (input: AiChatRequest) => {
const usage = "usage" in result ? result.usage ?? null : null
return { content, usage }
}
/**
* 流式 AI 聊天补全
*
* 返回 AsyncGenerator逐 token 产出内容。
* 用于 SSE 流式响应,降低用户感知延迟。
*/
export async function* createAiChatCompletionStream(
input: AiChatRequest
): AsyncGenerator<string, void, unknown> {
const config = await getAiProviderConfig(input.providerId)
const client = await getAiClient(config)
const stream = await client.chat.completions.create({
model: config.model || input.model,
messages: input.messages,
temperature: input.temperature,
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
stream: true,
})
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
const content = extractMessageContent(delta)
if (content) {
yield content
}
}
}

View File

@@ -1,5 +1,5 @@
export { encryptAiApiKey, decryptAiApiKey } from "./api-key-crypto"
export { createAiChatCompletion, testAiProviderById, testAiProviderConfig } from "./client"
export { createAiChatCompletion, createAiChatCompletionStream, testAiProviderById, testAiProviderConfig } from "./client"
export { getAiErrorMessage } from "./errors"
export { parseAiChatPayload, isRecord } from "./payload-parser"
export type { AiChatRequest, ChatMessage, ChatRole } from "./payload-parser"

View File

@@ -60,6 +60,7 @@ export type EventName =
| "homework.auto_save_failed"
// AI 模块监控事件
| "ai.chat"
| "ai.chat_stream"
| "ai.similar_question"
| "ai.grading_assist"
| "ai.lesson_content"