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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
182
src/app/api/ai/chat/stream/route.ts
Normal file
182
src/app/api/ai/chat/stream/route.ts
Normal 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" },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
217
src/modules/ai/components/ai-assistant-widget.tsx
Normal file
217
src/modules/ai/components/ai-assistant-widget.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
186
src/modules/ai/components/ai-child-summary.tsx
Normal file
186
src/modules/ai/components/ai-child-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
119
src/modules/ai/components/ai-markdown-renderer.tsx
Normal file
119
src/modules/ai/components/ai-markdown-renderer.tsx
Normal 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)
|
||||
@@ -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
|
||||
|
||||
193
src/modules/ai/components/ai-study-path.tsx
Normal file
193
src/modules/ai/components/ai-study-path.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
221
src/modules/ai/components/ai-usage-dashboard.tsx
Normal file
221
src/modules/ai/components/ai-usage-dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
190
src/modules/ai/hooks/use-ai-chat-stream.ts
Normal file
190
src/modules/ai/hooks/use-ai-chat-stream.ts
Normal 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 }
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
173
src/modules/ai/services/content-safety.ts
Normal file
173
src/modules/ai/services/content-safety.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 学习路径"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user