feat(ai): 新增 AI 模块并集成至备课/错题集/试卷/改题四大业务场景

- 新增 src/modules/ai 独立模块,遵循三层架构(actions → services → shared/lib/ai)
- 通过 AiClientProvider + useAiClient 实现 React Context 依赖注入,业务组件零直接 import
- 6 个 Server Actions 均调用 requirePermission() 权限校验,返回 ActionState<T>
- withAiTracking 统一埋点,覆盖 chat/similar_question/grading_assist/lesson_content/question_variant/weakness_analysis
- 集成场景:作业批改 AiGradingAssist、错题集 AiErrorBookAnalysis、备课 AiLessonContentGenerator、试卷 AiQuestionVariantGenerator
- 全量 i18n(en/zh-CN ai.json),Error Boundary + Skeleton 边界处理
- 同步架构图 004/005,新增审计报告 ai-module-audit-report.md
This commit is contained in:
SpecialX
2026-06-23 00:52:39 +08:00
parent ec87cd9efa
commit 21c5eba96c
40 changed files with 4885 additions and 169 deletions

View File

@@ -0,0 +1,127 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access"
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters"
import { ErrorBookList } from "@/modules/error-book/components/error-book-list"
import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog"
import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
export const dynamic = "force-dynamic"
const VALID_STATUS = new Set(["new", "learning", "mastered", "archived"])
const VALID_SOURCE = new Set(["exam", "homework", "manual"])
function parseStatus(v?: string): ErrorBookStatusValue | undefined {
return v && VALID_STATUS.has(v) ? (v as ErrorBookStatusValue) : undefined
}
function parseSource(v?: string): ErrorBookSourceTypeValue | undefined {
return v && VALID_SOURCE.has(v) ? (v as ErrorBookSourceTypeValue) : undefined
}
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}
async function ErrorBookResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const params = await searchParams
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
const q = getParam(params, "q")
const status = parseStatus(getParam(params, "status"))
const sourceType = parseSource(getParam(params, "source"))
const dueOnly = getParam(params, "due") === "due"
const { data: items } = await getErrorBookItems({
studentId: ctx.userId,
q: q || undefined,
status,
sourceType,
dueOnly,
pageSize: 50,
})
return <ErrorBookList items={items} studentId={ctx.userId} errorItems={items} />
}
function ErrorBookResultsFallback() {
return (
<div className="grid gap-3 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, idx) => (
<Skeleton key={idx} className="h-[180px] w-full rounded-md" />
))}
</div>
)
}
export default async function StudentErrorBookPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
const stats = await getErrorBookStats(ctx.userId)
const aiClientService = createAiClientService()
return (
<AiClientProvider service={aiClientService}>
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">
</p>
</div>
<div className="flex items-center gap-2">
<AddErrorBookDialog />
</div>
</div>
<ErrorBookStatsCards stats={stats} />
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ErrorBookFilters />
</Suspense>
<Suspense fallback={<ErrorBookResultsFallback />}>
<ErrorBookResults searchParams={searchParams} />
</Suspense>
</div>
</div>
</AiClientProvider>
)
}

View File

@@ -7,13 +7,45 @@ import { normalizeStructure } from "@/modules/exams/utils/normalize-structure"
import type { Question } from "@/modules/questions/types"
import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list"
import { createId } from "@paralleldrive/cuid2"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
export const dynamic = "force-dynamic"
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
const { id } = await params
const exam = await getExamById(id)
const ctx = await requirePermission(Permissions.EXAM_READ)
const exam = await getExamById(id, ctx.dataScope)
if (!exam) return notFound()
// Fetch initial questions for the bank (pagination handled by client)
@@ -69,24 +101,28 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
}))
}
const aiClientService = createAiClientService()
return (
<div className="flex h-full flex-col space-y-4 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
<p className="text-muted-foreground">Assemble questions for your exam.</p>
<AiClientProvider service={aiClientService}>
<div className="flex h-full flex-col space-y-4 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
<p className="text-muted-foreground">Assemble questions for your exam.</p>
</div>
<ExamAssembly
examId={exam.id}
title={exam.title}
subject={exam.subject}
grade={exam.grade}
difficulty={exam.difficulty}
totalScore={exam.totalScore}
durationMin={exam.durationMin}
initialSelected={initialSelected}
initialStructure={initialStructure}
questionOptions={questionOptions}
/>
</div>
<ExamAssembly
examId={exam.id}
title={exam.title}
subject={exam.subject}
grade={exam.grade}
difficulty={exam.difficulty}
totalScore={exam.totalScore}
durationMin={exam.durationMin}
initialSelected={initialSelected}
initialStructure={initialStructure}
questionOptions={questionOptions}
/>
</div>
</AiClientProvider>
)
}

View File

@@ -3,15 +3,46 @@ import { notFound } from "next/navigation"
import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view"
import { formatDate } from "@/shared/lib/utils"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
suggestGradingAction,
aiChatAction,
suggestSimilarQuestionsAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
export const dynamic = "force-dynamic"
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise<JSX.Element> {
const { submissionId } = await params
const submission = await getHomeworkSubmissionDetails(submissionId)
if (!submission) return notFound()
const aiClientService = createAiClientService()
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex items-center justify-between">
@@ -29,17 +60,19 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
</div>
</div>
<HomeworkGradingView
submissionId={submission.id}
studentName={submission.studentName}
assignmentTitle={submission.assignmentTitle}
submittedAt={submission.submittedAt}
status={submission.status}
totalScore={submission.totalScore}
answers={submission.answers}
prevSubmissionId={submission.prevSubmissionId}
nextSubmissionId={submission.nextSubmissionId}
/>
<AiClientProvider service={aiClientService}>
<HomeworkGradingView
submissionId={submission.id}
studentName={submission.studentName}
assignmentTitle={submission.assignmentTitle}
submittedAt={submission.submittedAt}
status={submission.status}
totalScore={submission.totalScore}
answers={submission.answers}
prevSubmissionId={submission.prevSubmissionId}
nextSubmissionId={submission.nextSubmissionId}
/>
</AiClientProvider>
</div>
)
}

View File

@@ -1,12 +1,44 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { notFound } from "next/navigation"
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { Skeleton } from "@/shared/components/ui/skeleton"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
export const dynamic = "force-dynamic"
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}
export default async function EditLessonPlanPage({
params,
}: {
@@ -23,16 +55,53 @@ export default async function EditLessonPlanPage({
const classes = teacherClasses.map((c) => ({ id: c.id, name: c.name }))
// 拉取教材/章节标题用于工具栏显示
let textbookTitle: string | undefined
let chapterTitle: string | undefined
if (plan.textbookId) {
const textbook = await getTextbookById(plan.textbookId)
textbookTitle = textbook?.title
if (plan.chapterId) {
const chapters = await getChaptersByTextbookId(plan.textbookId)
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
for (const ch of list) {
if (ch.id === plan.chapterId) return ch
if (ch.children && ch.children.length > 0) {
const found = findChapter(ch.children as typeof chapters)
if (found) return found
}
}
return undefined
}
const chapter = findChapter(chapters)
chapterTitle = chapter?.title
}
}
const aiClientService = createAiClientService()
return (
<div className="h-[calc(100vh-4rem)]">
<LessonPlanEditor
planId={plan.id}
initialTitle={plan.title}
initialDoc={plan.content}
textbookId={plan.textbookId ?? undefined}
chapterId={plan.chapterId ?? undefined}
classes={classes}
/>
</div>
<AiClientProvider service={aiClientService}>
<div className="h-[calc(100vh-4rem)]">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<Skeleton className="h-[80%] w-[80%]" />
</div>
}
>
<LessonPlanEditor
planId={plan.id}
initialTitle={plan.title}
initialDoc={plan.content}
textbookId={plan.textbookId ?? undefined}
chapterId={plan.chapterId ?? undefined}
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
classes={classes}
/>
</Suspense>
</div>
</AiClientProvider>
)
}

View File

@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai"
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic"
@@ -13,21 +14,20 @@ const getStatusFromError = (message: string) => {
}
export async function POST(req: Request) {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
// Rate limit AI chat per user
const limitKey = rateLimitKey("ai-chat", userId)
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT })
if (!limit.success) {
return NextResponse.json(
{ success: false, message: "Rate limit exceeded. Please slow down." },
{ status: 429, headers: rateLimitHeaders(limit) }
)
}
try {
const ctx = await requirePermission(Permissions.AI_CHAT)
const userId = ctx.userId
// Rate limit AI chat per user
const limitKey = rateLimitKey("ai-chat", userId)
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT })
if (!limit.success) {
return NextResponse.json(
{ success: false, message: "Rate limit exceeded. Please slow down." },
{ status: 429, headers: rateLimitHeaders(limit) }
)
}
const body = await req.json().catch(() => null)
const input = parseAiChatPayload(body)
const result = await createAiChatCompletion(input)
@@ -36,6 +36,12 @@ export async function POST(req: Request) {
{ headers: rateLimitHeaders(limit) }
)
} catch (e) {
if (e instanceof PermissionDeniedError) {
return NextResponse.json(
{ success: false, message: e.message },
{ status: 403 }
)
}
const message = getAiErrorMessage(e)
return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) })
}

View File

@@ -39,6 +39,7 @@ export default getRequestConfig(async () => {
attendance,
elective,
school,
ai,
] = await Promise.all([
import(`@/shared/i18n/messages/${locale}/common.json`),
import(`@/shared/i18n/messages/${locale}/auth.json`),
@@ -59,6 +60,7 @@ export default getRequestConfig(async () => {
import(`@/shared/i18n/messages/${locale}/attendance.json`),
import(`@/shared/i18n/messages/${locale}/elective.json`),
import(`@/shared/i18n/messages/${locale}/school.json`),
import(`@/shared/i18n/messages/${locale}/ai.json`),
]);
return {
@@ -83,6 +85,7 @@ export default getRequestConfig(async () => {
attendance: attendance.default,
elective: elective.default,
school: school.default,
ai: ai.default,
},
};
});

244
src/modules/ai/actions.ts Normal file
View File

@@ -0,0 +1,244 @@
"use server"
import { getTranslations } from "next-intl/server"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { createAiService, safeAiCall } from "./services/ai-service"
import {
AiChatInputSchema,
GradingInputSchema,
LessonContentInputSchema,
QuestionVariantInputSchema,
SimilarQuestionInputSchema,
WeaknessAnalysisInputSchema,
} from "./schema"
import type {
AiChatMessage,
AiChatResult,
GradingInput,
GradingSuggestion,
LessonContentInput,
LessonContentResult,
QuestionVariantInput,
QuestionVariantResult,
SimilarQuestionInput,
SimilarQuestionResult,
WeaknessAnalysisInput,
WeaknessAnalysisResult,
} from "./types"
// ---------------------------------------------------------------------------
// 辅助:并行校验多个权限
// ---------------------------------------------------------------------------
const requireAiPermission = async (
...permissions: readonly string[]
): Promise<{ userId: string }> => {
const results = await Promise.all(
permissions.map((p) => requirePermission(p as never))
)
return { userId: results[0].userId }
}
// ---------------------------------------------------------------------------
// AI 聊天
// ---------------------------------------------------------------------------
export async function aiChatAction(input: {
messages: AiChatMessage[]
providerId?: string
}): Promise<ActionState<AiChatResult>> {
const t = await getTranslations("ai")
try {
const ctx = await requirePermission(Permissions.AI_CHAT)
const parsed = AiChatInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() =>
service.chat(parsed.data.messages, {
providerId: parsed.data.providerId,
})
)
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("error.chatFailed") }
}
}
// ---------------------------------------------------------------------------
// 相似题推荐
// ---------------------------------------------------------------------------
export async function suggestSimilarQuestionsAction(
input: SimilarQuestionInput
): Promise<ActionState<SimilarQuestionResult[]>> {
const t = await getTranslations("ai")
try {
const ctx = await requireAiPermission(
Permissions.AI_CHAT,
Permissions.ERROR_BOOK_READ
)
const parsed = SimilarQuestionInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() =>
service.suggestSimilarQuestions(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("error.suggestionFailed") }
}
}
// ---------------------------------------------------------------------------
// AI 辅助批改
// ---------------------------------------------------------------------------
export async function suggestGradingAction(
input: GradingInput
): Promise<ActionState<GradingSuggestion>> {
const t = await getTranslations("ai")
try {
const ctx = await requireAiPermission(
Permissions.AI_CHAT,
Permissions.HOMEWORK_GRADE
)
const parsed = GradingInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() => service.suggestGrading(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("error.gradingFailed") }
}
}
// ---------------------------------------------------------------------------
// 备课内容生成
// ---------------------------------------------------------------------------
export async function generateLessonContentAction(
input: LessonContentInput
): Promise<ActionState<LessonContentResult>> {
const t = await getTranslations("ai")
try {
const ctx = await requireAiPermission(
Permissions.AI_CHAT,
Permissions.LESSON_PLAN_READ
)
const parsed = LessonContentInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() =>
service.generateLessonContent(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("error.contentFailed") }
}
}
// ---------------------------------------------------------------------------
// 题目变体生成
// ---------------------------------------------------------------------------
export async function generateQuestionVariantAction(
input: QuestionVariantInput
): Promise<ActionState<QuestionVariantResult>> {
const t = await getTranslations("ai")
try {
const ctx = await requireAiPermission(
Permissions.AI_CHAT,
Permissions.EXAM_AI_GENERATE
)
const parsed = QuestionVariantInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() =>
service.generateQuestionVariant(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("error.variantFailed") }
}
}
// ---------------------------------------------------------------------------
// 薄弱点分析
// ---------------------------------------------------------------------------
export async function analyzeWeaknessAction(
input: WeaknessAnalysisInput
): Promise<ActionState<WeaknessAnalysisResult>> {
const t = await getTranslations("ai")
try {
const ctx = await requireAiPermission(
Permissions.AI_CHAT,
Permissions.ERROR_BOOK_READ
)
const parsed = WeaknessAnalysisInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() => service.analyzeWeakness(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("error.analysisFailed") }
}
}

View File

@@ -0,0 +1,182 @@
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl"
import { Send, Bot, User } 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 { AiChatSkeleton } from "./ai-skeleton"
import { useAiClient } from "../context/ai-client-provider"
import type { AiChatMessage } from "../types"
type AiChatPanelProps = {
/** 初始系统提示词 */
systemPrompt?: string
/** 上下文信息(注入到 user message 前面) */
contextMessage?: string
/** 占位提示文本 */
placeholder?: string
/** 标题 */
title?: string
/** 最大消息数 */
maxMessages?: number
}
/**
* AI 聊天面板
*
* 通用 AI 对话组件,可嵌入任何页面。
* 通过 useAiClient() 获取 Server Action 引用,不直接 import actions。
*/
export function AiChatPanel({
systemPrompt,
contextMessage,
placeholder,
title,
maxMessages = 50,
}: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [messages, setMessages] = useState<AiChatMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
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 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 requestMessages: AiChatMessage[] = [
...(systemMessage ? [systemMessage] : []),
...messages,
{ role: "user" as const, 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])
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
void handleSend()
}
}
if (loading && messages.length === 0) {
return <AiChatSkeleton />
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{title ?? t("chat.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{messages.length > 0 ? (
<ScrollArea className="h-[300px] w-full rounded-md border p-3">
<div className="space-y-3" ref={scrollRef}>
{messages.map((message, index) => (
<div
key={index}
className={`flex gap-2 ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
{message.role === "assistant" ? (
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
) : (
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
)}
<div
className={`rounded-md px-3 py-2 text-sm max-w-[80%] ${
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
</div>
))}
{loading ? (
<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>
</div>
</div>
) : null}
</div>
</ScrollArea>
) : null}
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-[60px] resize-none"
disabled={loading || 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>
</div>
{messages.length >= maxMessages ? (
<p className="text-xs text-muted-foreground text-center">
{t("chat.maxReached")}
</p>
) : null}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,246 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, Lightbulb, BookOpen, TrendingDown } 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 { WeaknessAnalysisResult, SimilarQuestionResult } from "@/modules/ai/types"
type AiErrorBookAnalysisProps = {
/** 错题列表(用于薄弱点分析) */
errorItems: Array<{
questionText: string
questionType: string
knowledgePointIds?: string[]
errorCount: number
masteryLevel: number
}>
/** 学生 ID */
studentId: string
/** 学科 ID */
subjectId?: string
/** 当前错题的题目文本(用于相似题推荐) */
currentQuestionText?: string
/** 当前题目类型 */
currentQuestionType?: string
/** 选中相似题后的回调 */
onSelectSimilarQuestion?: (question: SimilarQuestionResult) => void
}
/**
* 错题本 AI 分析组件
*
* 集成两个 AI 能力:
* 1. 相似题推荐:根据当前错题生成同类练习
* 2. 薄弱点分析:分析错题分布,生成学习建议
*
* 通过 AiClientProvider 注入服务,不直接 import actions。
*/
export function AiErrorBookAnalysis({
errorItems,
studentId,
subjectId,
currentQuestionText,
currentQuestionType,
onSelectSimilarQuestion,
}: AiErrorBookAnalysisProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [similarLoading, setSimilarLoading] = useState(false)
const [weaknessLoading, setWeaknessLoading] = useState(false)
const [similarQuestions, setSimilarQuestions] = useState<SimilarQuestionResult[]>([])
const [weaknessResult, setWeaknessResult] = useState<WeaknessAnalysisResult | null>(null)
const handleGenerateSimilar = async (): Promise<void> => {
if (!currentQuestionText || !currentQuestionType) return
setSimilarLoading(true)
try {
const result = await aiClient.suggestSimilarQuestions({
questionText: currentQuestionText,
questionType: currentQuestionType,
subject: subjectId,
count: 3,
})
if (result.success && result.data) {
setSimilarQuestions(result.data)
toast.success(t("suggestion.loaded"))
} else {
toast.error(result.message ?? t("suggestion.error"))
}
} catch {
toast.error(t("suggestion.error"))
} finally {
setSimilarLoading(false)
}
}
const handleAnalyzeWeakness = async (): Promise<void> => {
if (errorItems.length === 0) return
setWeaknessLoading(true)
try {
const result = await aiClient.analyzeWeakness({
studentId,
subjectId,
errorItems,
})
if (result.success && result.data) {
setWeaknessResult(result.data)
toast.success(t("errorBook.weaknessAnalysis"))
} else {
toast.error(result.message ?? t("error.analysisFailed"))
}
} catch {
toast.error(t("error.analysisFailed"))
} finally {
setWeaknessLoading(false)
}
}
const severityVariant = (severity: "high" | "medium" | "low"): "destructive" | "secondary" | "outline" => {
if (severity === "high") return "destructive"
if (severity === "medium") return "secondary"
return "outline"
}
return (
<AiErrorBoundary>
<div className="space-y-4">
{/* 相似题推荐 */}
{currentQuestionText ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("errorBook.similarQuestions")}
</CardTitle>
<CardDescription>{t("suggestion.title")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{similarLoading ? (
<AiSuggestionSkeleton />
) : similarQuestions.length > 0 ? (
<>
{similarQuestions.map((question, index) => (
<div key={index} className="rounded-md border p-3 space-y-2">
<p className="text-sm">{question.text}</p>
{question.difficulty ? (
<Badge variant="outline" className="text-xs">
{t("suggestion.difficulty")}: {question.difficulty}
</Badge>
) : null}
{question.options && question.options.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-1">
{question.options.map((opt, optIndex) => (
<li key={optIndex}>
<span className="font-medium">{opt.id}.</span> {opt.text}
</li>
))}
</ul>
) : null}
{question.explanation ? (
<p className="text-xs text-muted-foreground italic">{question.explanation}</p>
) : null}
{onSelectSimilarQuestion ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onSelectSimilarQuestion(question)}
>
{t("suggestion.select")}
</Button>
) : null}
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={handleGenerateSimilar} className="w-full">
{t("suggestion.regenerate")}
</Button>
</>
) : (
<Button type="button" variant="outline" size="sm" onClick={handleGenerateSimilar} className="w-full">
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("suggestion.generate")}
</Button>
)}
</CardContent>
</Card>
) : null}
{/* 薄弱点分析 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-primary" />
{t("errorBook.weaknessAnalysis")}
</CardTitle>
<CardDescription>{t("errorBook.weakAreas")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{weaknessLoading ? (
<AiSuggestionSkeleton />
) : weaknessResult ? (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("errorBook.weakAreas")}</h4>
{weaknessResult.weakAreas.map((area, index) => (
<div key={index} className="rounded-md border p-3 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{area.area}</span>
<Badge variant={severityVariant(area.severity)}>
{t(`errorBook.severity.${area.severity}`)}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{area.suggestion}</p>
</div>
))}
</div>
<div className="space-y-1">
<h4 className="text-sm font-medium flex items-center gap-1">
<Lightbulb className="h-3.5 w-3.5" />
{t("errorBook.studyPlan")}
</h4>
<p className="text-sm text-muted-foreground">{weaknessResult.studyPlan}</p>
</div>
{weaknessResult.recommendedResources.length > 0 ? (
<div className="space-y-1">
<h4 className="text-sm font-medium flex items-center gap-1">
<BookOpen className="h-3.5 w-3.5" />
{t("errorBook.recommendedResources")}
</h4>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
{weaknessResult.recommendedResources.map((resource, index) => (
<li key={index}>{resource}</li>
))}
</ul>
</div>
) : null}
<Button type="button" variant="outline" size="sm" onClick={handleAnalyzeWeakness} className="w-full">
{t("suggestion.regenerate")}
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAnalyzeWeakness}
disabled={errorItems.length === 0}
className="w-full"
>
<TrendingDown className="mr-1 h-3.5 w-3.5" />
{t("suggestion.generate")}
</Button>
)}
</CardContent>
</Card>
</div>
</AiErrorBoundary>
)
}

View File

@@ -0,0 +1,88 @@
"use client"
import { Component, type ReactNode } from "react"
import { AlertCircle, RefreshCw } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
type AiErrorBoundaryProps = {
children: ReactNode
/** 自定义 fallback 渲染 */
fallback?: (error: Error, reset: () => void) => ReactNode
/** 错误回调(用于埋点) */
onError?: (error: Error, info: unknown) => void
}
type AiErrorBoundaryState = {
error: Error | null
}
/**
* AI 专用 Error Boundary
*
* 包裹所有 AI 数据区块,防止单个 AI 调用失败导致整页崩溃。
* 提供重试按钮与友好的错误提示。
*/
export class AiErrorBoundary extends Component<
AiErrorBoundaryProps,
AiErrorBoundaryState
> {
constructor(props: AiErrorBoundaryProps) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error: Error): AiErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: unknown): void {
if (this.props.onError) {
this.props.onError(error, info)
}
}
private handleReset = (): void => {
this.setState({ error: null })
}
render(): ReactNode {
if (this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleReset)
}
return <DefaultAiErrorFallback error={this.state.error} onReset={this.handleReset} />
}
return this.props.children
}
}
function DefaultAiErrorFallback({
error,
onReset,
}: {
error: Error
onReset: () => void
}): ReactNode {
const t = useTranslations("ai")
return (
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
{t("error.boundaryTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{t("error.boundaryDescription")}</p>
<p className="text-xs text-muted-foreground/70 font-mono">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={onReset}>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("error.retry")}
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,173 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, Check } 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 { Progress } from "@/shared/components/ui/progress"
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 { GradingSuggestion } from "@/modules/ai/types"
type AiGradingAssistProps = {
/** 题目文本 */
questionText: string
/** 题目类型 */
questionType: string
/** 学生答案 */
studentAnswer: string
/** 正确答案(可选) */
correctAnswer?: string
/** 最大分值 */
maxScore: number
/** 学科 */
subject?: string
/** 应用建议分数 */
onApplyScore?: (score: number) => void
/** 应用建议反馈 */
onApplyFeedback?: (feedback: string) => void
}
/**
* AI 批改辅助组件
*
* 为教师提供 AI 预评分与反馈建议。
* 仅用于主观题text/essay客观题由系统自动判分。
*/
export function AiGradingAssist({
questionText,
questionType,
studentAnswer,
correctAnswer,
maxScore,
subject,
onApplyScore,
onApplyFeedback,
}: AiGradingAssistProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [suggestion, setSuggestion] = useState<GradingSuggestion | null>(null)
// 仅对主观题提供 AI 批改
const isAutoGradable = questionType === "single_choice" || questionType === "multiple_choice" || questionType === "judgment"
if (isAutoGradable) {
return null
}
const handleGenerate = async (): Promise<void> => {
setLoading(true)
try {
const result = await aiClient.suggestGrading({
questionText,
questionType,
studentAnswer,
correctAnswer,
maxScore,
subject,
})
if (result.success && result.data) {
setSuggestion(result.data)
toast.success(t("grading.title"))
} else {
toast.error(result.message ?? t("grading.error"))
}
} catch {
toast.error(t("grading.error"))
} finally {
setLoading(false)
}
}
const confidencePercent = suggestion ? Math.round(suggestion.confidence * 100) : 0
return (
<AiErrorBoundary>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("grading.title")}
</CardTitle>
<CardDescription>{t("grading.title")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{loading ? (
<AiSuggestionSkeleton />
) : suggestion ? (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("grading.suggestedScore")}</span>
<Badge variant="secondary" className="text-base">
{suggestion.suggestedScore} / {maxScore}
</Badge>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t("grading.confidence")}</span>
<span>{confidencePercent}%</span>
</div>
<Progress value={confidencePercent} className="h-1.5" />
</div>
</div>
<div className="space-y-1">
<h4 className="text-sm font-medium">{t("grading.feedback")}</h4>
<p className="text-sm text-muted-foreground rounded-md bg-muted p-2">
{suggestion.feedback}
</p>
</div>
<div className="space-y-1">
<h4 className="text-sm font-medium">{t("grading.reasoning")}</h4>
<p className="text-xs text-muted-foreground">{suggestion.reasoning}</p>
</div>
<div className="flex gap-2">
{onApplyScore ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onApplyScore(suggestion.suggestedScore)
toast.success(t("grading.suggestedScore"))
}}
>
<Check className="mr-1 h-3.5 w-3.5" />
{t("grading.applyScore")}
</Button>
) : null}
{onApplyFeedback ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onApplyFeedback(suggestion.feedback)
toast.success(t("grading.feedback"))
}}
>
<Check className="mr-1 h-3.5 w-3.5" />
{t("grading.applyFeedback")}
</Button>
) : null}
<Button type="button" variant="ghost" size="sm" onClick={handleGenerate}>
{t("suggestion.regenerate")}
</Button>
</div>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("grading.title")}
</Button>
)}
</CardContent>
</Card>
</AiErrorBoundary>
)
}

View File

@@ -0,0 +1,187 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, BookOpen, Lightbulb, HelpCircle, FileText } 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 { Textarea } from "@/shared/components/ui/textarea"
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 { LessonContentResult } from "@/modules/ai/types"
type ContentType = "activity" | "assessment" | "question" | "material"
type AiLessonContentGeneratorProps = {
/** 备课主题 */
topic: string
/** 学科 */
subject?: string
/** 年级 */
grade?: string
/** 教材 ID */
textbookId?: string
/** 章节 ID */
chapterId?: string
/** 生成内容后的回调 */
onInsertContent?: (result: LessonContentResult) => void
}
const CONTENT_TYPE_ICONS: Record<ContentType, typeof Sparkles> = {
activity: Lightbulb,
assessment: FileText,
question: HelpCircle,
material: BookOpen,
}
/**
* AI 备课内容生成器
*
* 为教师提供 AI 生成教学活动、评估题、讨论题、教学素材的能力。
* 通过 AiClientProvider 注入服务,不直接 import actions。
*
* 使用场景:在备课编辑器侧边栏中作为辅助工具使用。
*/
export function AiLessonContentGenerator({
topic,
subject,
grade,
textbookId,
chapterId,
onInsertContent,
}: AiLessonContentGeneratorProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<LessonContentResult | null>(null)
const [activeType, setActiveType] = useState<ContentType>("activity")
const [additionalContext, setAdditionalContext] = useState("")
const handleGenerate = async (): Promise<void> => {
if (!topic.trim()) {
toast.error(t("lessonPrep.error"))
return
}
setLoading(true)
try {
const response = await aiClient.generateLessonContent({
topic,
subject,
grade,
textbookId,
chapterId,
contentType: activeType,
additionalContext: additionalContext.trim() || undefined,
})
if (response.success && response.data) {
setResult(response.data)
toast.success(t("lessonPrep.generateContent"))
} else {
toast.error(response.message ?? t("lessonPrep.error"))
}
} catch {
toast.error(t("lessonPrep.error"))
} finally {
setLoading(false)
}
}
const contentTypes: Array<{ type: ContentType; label: string; icon: typeof Sparkles }> = [
{ type: "activity", label: t("lessonPrep.generateActivity"), icon: Lightbulb },
{ type: "assessment", label: t("lessonPrep.generateAssessment"), icon: FileText },
{ type: "question", label: t("lessonPrep.generateQuestion"), icon: HelpCircle },
{ type: "material", label: t("lessonPrep.generateContent"), icon: BookOpen },
]
return (
<AiErrorBoundary>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("lessonPrep.generateContent")}
</CardTitle>
<CardDescription>{t("lessonPrep.generateContent")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 内容类型选择 */}
<div className="grid grid-cols-2 gap-2">
{contentTypes.map(({ type, label, icon: Icon }) => (
<Button
key={type}
type="button"
variant={activeType === type ? "default" : "outline"}
size="sm"
className="justify-start"
onClick={() => setActiveType(type)}
>
<Icon className="mr-1 h-3.5 w-3.5" />
{label}
</Button>
))}
</div>
{/* 附加上下文 */}
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor="ai-additional-context">
{t("lessonPrep.generateContent")}
</label>
<Textarea
id="ai-additional-context"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
placeholder={t("lessonPrep.generateContent")}
className="min-h-[60px] text-sm"
maxLength={500}
/>
</div>
{/* 生成按钮 */}
<Button
type="button"
onClick={handleGenerate}
disabled={loading || !topic.trim()}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{loading ? t("lessonPrep.loading") : t("lessonPrep.generateContent")}
</Button>
{/* 生成结果 */}
{loading ? (
<AiSuggestionSkeleton />
) : result ? (
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-medium">{result.title}</h4>
<Badge variant="secondary" className="text-xs">
{activeType}
</Badge>
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{result.content}
</p>
{onInsertContent ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onInsertContent(result)
toast.success(t("lessonPrep.generateContent"))
}}
>
{t("lessonPrep.generateContent")}
</Button>
) : null}
</div>
) : null}
</CardContent>
</Card>
</AiErrorBoundary>
)
}

View File

@@ -0,0 +1,129 @@
"use client"
import { useTranslations } from "next-intl"
import { Settings } from "lucide-react"
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription,
} from "@/shared/components/ui/form"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import type { Control } from "react-hook-form"
/** AI Provider 摘要信息(与 settings 模块类型兼容) */
export type AiProviderOption = {
id: string
provider: string
model: string
isDefault: boolean
}
type AiProviderSelectorProps = {
/** react-hook-form control */
control: Control<Record<string, unknown>>
/** 表单字段名 */
name: string
/** Provider 列表 */
providers: AiProviderOption[]
/** 是否加载中 */
loading?: boolean
/** Provider 标签映射 */
providerLabels?: Record<string, string>
/** 管理面板触发器 */
managePanel?: React.ReactNode
/** 管理面板打开状态 */
manageOpen?: boolean
onManageOpenChange?: (open: boolean) => void
}
/**
* AI Provider 选择器
*
* 可复用的表单字段组件,用于选择 AI Provider。
* 从 exam-ai-generator.tsx 抽取,支持在任何需要 AI Provider 选择的表单中复用。
*/
export function AiProviderSelector({
control,
name,
providers,
loading = false,
providerLabels,
managePanel,
manageOpen,
onManageOpenChange,
}: AiProviderSelectorProps): React.ReactNode {
const t = useTranslations("ai")
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("provider.label")}</FormLabel>
{managePanel ? (
<Dialog open={manageOpen} onOpenChange={onManageOpenChange}>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
>
<Settings className="mr-1 h-3.5 w-3.5" />
{t("provider.manage")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
<DialogDescription>{t("provider.manageDescription")}</DialogDescription>
</DialogHeader>
{managePanel}
</DialogContent>
</Dialog>
) : null}
</div>
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={loading ? t("provider.loading") : t("provider.placeholder")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{providerLabels?.[item.provider] ?? item.provider} · {item.model}
{item.isDefault ? ` (${t("provider.default")})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>{t("provider.description")}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,208 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, RefreshCw, Plus } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
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 { QuestionVariantResult } from "@/modules/ai/types"
type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format"
type AiQuestionVariantGeneratorProps = {
/** 原始题目 */
originalQuestion: {
text: string
type: string
difficulty?: number
options?: Array<{ id: string; text: string; isCorrect?: boolean }>
answer?: string
}
/** 学科 */
subject?: string
/** 生成变体后的回调 */
onAddVariant?: (variant: QuestionVariantResult) => void
}
/**
* AI 题目变体生成器
*
* 为教师提供从现有题目生成变体的能力:
* - same_knowledge_point: 同知识点不同表述
* - different_difficulty: 调整难度
* - different_format: 转换题型
*
* 通过 AiClientProvider 注入服务,不直接 import actions。
*/
export function AiQuestionVariantGenerator({
originalQuestion,
subject,
onAddVariant,
}: AiQuestionVariantGeneratorProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [variant, setVariant] = useState<QuestionVariantResult | null>(null)
const [variantType, setVariantType] = useState<VariantType>("same_knowledge_point")
const handleGenerate = async (): Promise<void> => {
if (!originalQuestion.text.trim()) {
toast.error(t("error.invalidInput"))
return
}
setLoading(true)
try {
const result = await aiClient.generateQuestionVariant({
originalQuestion,
subject,
variantType,
})
if (result.success && result.data) {
setVariant(result.data)
toast.success(t("exam.generate"))
} else {
toast.error(result.message ?? t("error.variantFailed"))
}
} catch {
toast.error(t("error.variantFailed"))
} finally {
setLoading(false)
}
}
const variantTypeLabels: Record<VariantType, string> = {
same_knowledge_point: t("exam.generate"),
different_difficulty: t("exam.generate"),
different_format: t("exam.generate"),
}
return (
<AiErrorBoundary>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("capability.questionVariant")}
</CardTitle>
<CardDescription>{t("exam.generate")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* 变体类型选择 */}
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor="variant-type">
{t("exam.generate")}
</label>
<Select
value={variantType}
onValueChange={(value) => setVariantType(value as VariantType)}
>
<SelectTrigger id="variant-type" className="w-full">
<SelectValue placeholder={t("exam.generate")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="same_knowledge_point">
{variantTypeLabels.same_knowledge_point}
</SelectItem>
<SelectItem value="different_difficulty">
{variantTypeLabels.different_difficulty}
</SelectItem>
<SelectItem value="different_format">
{variantTypeLabels.different_format}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 生成按钮 */}
<Button
type="button"
onClick={handleGenerate}
disabled={loading || !originalQuestion.text.trim()}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{loading ? t("exam.generating") : t("exam.generate")}
</Button>
{/* 生成结果 */}
{loading ? (
<AiSuggestionSkeleton />
) : variant ? (
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-medium">{variant.text}</h4>
<Badge variant="secondary" className="text-xs">
{t("suggestion.difficulty")}: {variant.difficulty}
</Badge>
</div>
{variant.options && variant.options.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-1">
{variant.options.map((opt, index) => (
<li key={index} className="flex items-center gap-1">
<span className="font-medium">{opt.id}.</span>
<span>{opt.text}</span>
{opt.isCorrect ? (
<Badge variant="outline" className="text-xs">
</Badge>
) : null}
</li>
))}
</ul>
) : null}
{variant.answer ? (
<div className="text-xs">
<span className="font-medium">{t("exam.sourceText")}:</span>{" "}
<span className="text-muted-foreground">{variant.answer}</span>
</div>
) : null}
{variant.explanation ? (
<p className="text-xs text-muted-foreground italic">
{variant.explanation}
</p>
) : null}
<div className="flex gap-2">
{onAddVariant ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onAddVariant(variant)
toast.success(t("exam.generate"))
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("exam.generate")}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleGenerate}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("suggestion.regenerate")}
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
</AiErrorBoundary>
)
}

View File

@@ -0,0 +1,47 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* AI 建议加载骨架屏
*
* 在 AI 异步调用期间显示,提供视觉反馈。
*/
export function AiSuggestionSkeleton(): React.ReactNode {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</CardContent>
</Card>
)
}
/**
* AI 聊天加载骨架屏
*/
export function AiChatSkeleton(): React.ReactNode {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,164 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, Check, X, RefreshCw } 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 { Badge } from "@/shared/components/ui/badge"
import { AiSuggestionSkeleton } from "./ai-skeleton"
import { useAiClient } from "../context/ai-client-provider"
import type { SimilarQuestionResult } from "../types"
type AiSuggestionCardProps = {
/** 原始题目文本 */
questionText: string
/** 题目类型 */
questionType: string
/** 学科 */
subject?: string
/** 知识点 ID 列表 */
knowledgePointIds?: string[]
/** 需要生成的题目数量 */
count?: number
/** 选中题目后的回调 */
onSelectQuestion?: (question: SimilarQuestionResult) => void
}
/**
* AI 相似题建议卡片
*
* 可复用组件,展示 AI 生成的相似练习题。
* 用于错题本、作业练习等场景。
*/
export function AiSuggestionCard({
questionText,
questionType,
subject,
knowledgePointIds,
count = 3,
onSelectQuestion,
}: AiSuggestionCardProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [questions, setQuestions] = useState<SimilarQuestionResult[]>([])
const [hasLoaded, setHasLoaded] = useState(false)
const handleGenerate = async (): Promise<void> => {
setLoading(true)
try {
const result = await aiClient.suggestSimilarQuestions({
questionText,
questionType,
subject,
knowledgePointIds,
count,
})
if (result.success && result.data) {
setQuestions(result.data)
setHasLoaded(true)
toast.success(t("suggestion.loaded"))
} else {
toast.error(result.message ?? t("suggestion.error"))
}
} catch {
toast.error(t("suggestion.error"))
} finally {
setLoading(false)
}
}
const handleSelect = (question: SimilarQuestionResult): void => {
onSelectQuestion?.(question)
toast.success(t("suggestion.selected"))
}
if (loading) {
return <AiSuggestionSkeleton />
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("suggestion.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{hasLoaded && questions.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("suggestion.empty")}</p>
) : questions.length > 0 ? (
<>
{questions.map((question, index) => (
<div
key={index}
className="rounded-md border p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm flex-1">{question.text}</p>
{question.difficulty ? (
<Badge variant="outline" className="shrink-0">
{t("suggestion.difficulty")}: {question.difficulty}
</Badge>
) : null}
</div>
{question.options && question.options.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-1">
{question.options.map((opt, optIndex) => (
<li key={optIndex}>
<span className="font-medium">{opt.id}.</span> {opt.text}
</li>
))}
</ul>
) : null}
{question.explanation ? (
<p className="text-xs text-muted-foreground italic">
{question.explanation}
</p>
) : null}
{onSelectQuestion ? (
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleSelect(question)}
>
<Check className="mr-1 h-3.5 w-3.5" />
{t("suggestion.select")}
</Button>
</div>
) : null}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerate}
className="w-full"
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("suggestion.regenerate")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerate}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("suggestion.generate")}
</Button>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,62 @@
"use client"
import { createContext, useContext, type ReactNode } from "react"
import type { AiClientService } from "../types"
/**
* AI 客户端服务 Context
*
* 通过 React Context 注入 AiClientServiceServer Action 引用集合),
* 客户端组件通过 useAiClient() 消费,不直接 import actions。
*
* 遵循 settings 模块的依赖注入模式:
* - 页面层Server Component创建 service 对象并注入 Provider
* - 组件层通过 Hook 消费
* - 测试时可注入 mock service
*/
// 重新导出 AiClientService 类型,方便调用方从单一入口导入
export type { AiClientService } from "../types"
const AiClientContext = createContext<AiClientService | null>(null)
export function AiClientProvider({
children,
service,
}: {
children: ReactNode
service: AiClientService
}) {
return (
<AiClientContext.Provider value={service}>
{children}
</AiClientContext.Provider>
)
}
/**
* 获取 AI 客户端服务
*
* 必须在 AiClientProvider 内部使用。
* 若未注入,抛出错误以防止静默失败。
*/
export function useAiClient(): AiClientService {
const service = useContext(AiClientContext)
if (!service) {
throw new Error(
"useAiClient must be used within an AiClientProvider. " +
"Wrap your component tree with <AiClientProvider service={...}>."
)
}
return service
}
/**
* 安全获取 AI 客户端服务(未注入时返回 null
*
* 用于可选 AI 功能的场景,组件需自行处理 null 情况。
*/
export function useAiClientOptional(): AiClientService | null {
return useContext(AiClientContext)
}

View File

@@ -0,0 +1,57 @@
"use client"
import { useState, useCallback } from "react"
import { useAiClient } from "../context/ai-client-provider"
import type { AiChatMessage, AiChatResult } from "../types"
/**
* AI 聊天 Hook
*
* 封装 AI 聊天逻辑,与 UI 分离。
* 通过 useAiClient() 获取 Server Action 引用。
*/
export function useAiChat(): {
messages: AiChatMessage[]
loading: boolean
error: string | null
send: (messages: AiChatMessage[], providerId?: string) => Promise<AiChatResult | null>
clear: () => void
} {
const aiClient = useAiClient()
const [messages, setMessages] = useState<AiChatMessage[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const send = useCallback(
async (input: AiChatMessage[], providerId?: string): Promise<AiChatResult | null> => {
setLoading(true)
setError(null)
try {
const result = await aiClient.chat({ messages: input, providerId })
if (result.success && result.data) {
const assistantContent = result.data.content
setMessages((prev) => [...prev, ...input, {
role: "assistant",
content: assistantContent,
}])
return result.data
}
setError(result.message ?? "AI request failed")
return null
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return null
} finally {
setLoading(false)
}
},
[aiClient]
)
const clear = useCallback((): void => {
setMessages([])
setError(null)
}, [])
return { messages, loading, error, send, clear }
}

View File

@@ -0,0 +1,72 @@
"use client"
import { useState, useCallback } from "react"
import { useAiClient } from "../context/ai-client-provider"
import type {
SimilarQuestionInput,
SimilarQuestionResult,
GradingInput,
GradingSuggestion,
} from "../types"
/**
* AI 建议 Hook
*
* 封装 AI 建议调用逻辑(相似题、批改建议等),与 UI 分离。
*/
export function useAiSuggestion(): {
loading: boolean
error: string | null
suggestSimilarQuestions: (
input: SimilarQuestionInput
) => Promise<SimilarQuestionResult[] | null>
suggestGrading: (input: GradingInput) => Promise<GradingSuggestion | null>
} {
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const suggestSimilarQuestions = useCallback(
async (input: SimilarQuestionInput): Promise<SimilarQuestionResult[] | null> => {
setLoading(true)
setError(null)
try {
const result = await aiClient.suggestSimilarQuestions(input)
if (result.success && result.data) {
return result.data
}
setError(result.message ?? "AI suggestion failed")
return null
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return null
} finally {
setLoading(false)
}
},
[aiClient]
)
const suggestGrading = useCallback(
async (input: GradingInput): Promise<GradingSuggestion | null> => {
setLoading(true)
setError(null)
try {
const result = await aiClient.suggestGrading(input)
if (result.success && result.data) {
return result.data
}
setError(result.message ?? "AI grading failed")
return null
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return null
} finally {
setLoading(false)
}
},
[aiClient]
)
return { loading, error, suggestSimilarQuestions, suggestGrading }
}

134
src/modules/ai/schema.ts Normal file
View File

@@ -0,0 +1,134 @@
import { z } from "zod"
// ---------------------------------------------------------------------------
// 基础校验
// ---------------------------------------------------------------------------
export const AiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string().min(1).max(8000),
})
export const AiChatInputSchema = z.object({
messages: z.array(AiChatMessageSchema).min(1).max(50),
providerId: z.string().min(1).optional(),
})
// ---------------------------------------------------------------------------
// 业务场景校验
// ---------------------------------------------------------------------------
export const SimilarQuestionInputSchema = z.object({
questionText: z.string().min(1).max(4000),
questionType: z.string().min(1),
subject: z.string().optional(),
knowledgePointIds: z.array(z.string()).optional(),
count: z.number().int().min(1).max(10).optional(),
})
export const GradingInputSchema = z.object({
questionText: z.string().min(1).max(4000),
questionType: z.string().min(1),
studentAnswer: z.string().min(1).max(8000),
correctAnswer: z.string().optional(),
maxScore: z.number().int().min(1).max(100),
subject: z.string().optional(),
})
export const LessonContentInputSchema = z.object({
topic: z.string().min(1).max(500),
subject: z.string().optional(),
grade: z.string().optional(),
textbookId: z.string().optional(),
chapterId: z.string().optional(),
contentType: z.enum(["activity", "assessment", "question", "material"]),
additionalContext: z.string().max(2000).optional(),
})
export const QuestionVariantInputSchema = z.object({
originalQuestion: z.object({
text: z.string().min(1).max(4000),
type: z.string().min(1),
difficulty: z.number().int().min(1).max(5).optional(),
options: z
.array(
z.object({
id: z.string().min(1),
text: z.string().min(1),
isCorrect: z.boolean().optional(),
})
)
.optional(),
answer: z.string().optional(),
}),
subject: z.string().optional(),
variantType: z.enum(["same_knowledge_point", "different_difficulty", "different_format"]),
})
export const WeaknessAnalysisInputSchema = z.object({
studentId: z.string().min(1),
subjectId: z.string().optional(),
errorItems: z
.array(
z.object({
questionText: z.string().min(1),
questionType: z.string().min(1),
knowledgePointIds: z.array(z.string()).optional(),
errorCount: z.number().int().min(1),
masteryLevel: z.number().int().min(0).max(5),
})
)
.min(1)
.max(100),
})
// ---------------------------------------------------------------------------
// AI 返回结果校验(用于解析 AI JSON 输出)
// ---------------------------------------------------------------------------
export const SimilarQuestionResultSchema = z.object({
text: z.string().min(1),
type: z.string().min(1),
difficulty: z.number().int().min(1).max(5).optional(),
options: z.array(z.object({ id: z.string(), text: z.string() })).optional(),
answer: z.string().optional(),
explanation: z.string().optional(),
})
export const SimilarQuestionListSchema = z.array(SimilarQuestionResultSchema)
export const GradingSuggestionSchema = z.object({
suggestedScore: z.number().min(0),
confidence: z.number().min(0).max(1),
feedback: z.string(),
reasoning: z.string(),
})
export const LessonContentResultSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export const QuestionVariantResultSchema = z.object({
text: z.string().min(1),
type: z.string().min(1),
difficulty: z.number().int().min(1).max(5),
options: z
.array(z.object({ id: z.string(), text: z.string(), isCorrect: z.boolean() }))
.optional(),
answer: z.string().optional(),
explanation: z.string().optional(),
})
export const WeaknessAnalysisResultSchema = z.object({
weakAreas: z.array(
z.object({
area: z.string().min(1),
severity: z.enum(["high", "medium", "low"]),
suggestion: z.string().min(1),
})
),
studyPlan: z.string().min(1),
recommendedResources: z.array(z.string()),
})

View File

@@ -0,0 +1,346 @@
import "server-only"
import { env } from "@/env.mjs"
import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"
import {
GRADING_ASSIST_SYSTEM_PROMPT,
LESSON_CONTENT_SYSTEM_PROMPT,
QUESTION_VARIANT_SYSTEM_PROMPT,
SIMILAR_QUESTION_SYSTEM_PROMPT,
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
} from "./prompt-templates"
import { withAiTracking } from "./usage-tracker"
import {
GradingSuggestionSchema,
LessonContentResultSchema,
QuestionVariantResultSchema,
SimilarQuestionListSchema,
WeaknessAnalysisResultSchema,
} from "../schema"
import type {
AiChatMessage,
AiChatOptions,
AiChatResult,
AiService,
GradingInput,
GradingSuggestion,
LessonContentInput,
LessonContentResult,
QuestionVariantInput,
QuestionVariantResult,
SimilarQuestionInput,
SimilarQuestionResult,
WeaknessAnalysisInput,
WeaknessAnalysisResult,
} from "../types"
// ---------------------------------------------------------------------------
// JSON 提取工具(从 AI 返回文本中提取 JSON
// ---------------------------------------------------------------------------
const extractBalancedJsonSegment = (value: string): string | null => {
const startBrace = value.indexOf("{")
const startBracket = value.indexOf("[")
const start =
startBrace === -1
? startBracket
: startBracket === -1
? startBrace
: Math.min(startBrace, startBracket)
if (start === -1) return null
const opening = value[start]
const closing = opening === "{" ? "}" : "]"
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < value.length; i += 1) {
const char = value[i]
if (inString) {
if (escaped) {
escaped = false
} else if (char === "\\") {
escaped = true
} else if (char === '"') {
inString = false
}
continue
}
if (char === '"') {
inString = true
continue
}
if (char === opening) {
depth += 1
continue
}
if (char === closing) {
depth -= 1
if (depth === 0) {
return value.slice(start, i + 1)
}
}
}
return null
}
const tryParseJson = (value: string): unknown | null => {
try {
return JSON.parse(value)
} catch {
return null
}
}
const extractJson = (raw: string): unknown => {
const trimmed = raw.trim()
const candidates: string[] = []
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)]
if (fencedMatches.length > 0) {
candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim()))
}
candidates.push(trimmed)
for (const candidate of candidates) {
const direct = tryParseJson(candidate)
if (direct !== null) return direct
const segment = extractBalancedJsonSegment(candidate)
if (!segment) continue
const parsed = tryParseJson(segment)
if (parsed !== null) return parsed
}
throw new Error("Invalid AI response: cannot parse JSON")
}
// ---------------------------------------------------------------------------
// AiService 实现
// ---------------------------------------------------------------------------
const DEFAULT_MODEL = () => String(env.AI_MODEL ?? "gpt-4o-mini")
const buildChatMessages = (
systemPrompt: string,
userContent: string
): AiChatMessage[] => [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
]
const callAi = async (
messages: AiChatMessage[],
options?: AiChatOptions
): Promise<{ content: string; model?: string; tokenUsage?: number }> => {
const result = await createAiChatCompletion({
messages,
model: options?.model ?? DEFAULT_MODEL(),
temperature: options?.temperature ?? 0.3,
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
...(options?.providerId ? { providerId: options.providerId } : {}),
})
const tokenUsage =
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
? Number((result.usage as unknown as Record<string, unknown>).total_tokens ?? 0)
: undefined
return { content: result.content, tokenUsage }
}
/**
* 默认 AI 服务实现
*
* 封装 shared/lib/ai 的底层 SDK 调用,提供业务语义化接口。
* 所有业务模块通过此服务调用 AI不直接 import shared/lib/ai。
*/
export class DefaultAiService implements AiService {
constructor(private readonly userId: string) {}
async chat(
messages: AiChatMessage[],
options?: AiChatOptions
): Promise<AiChatResult> {
return withAiTracking(this.userId, "chat", options?.providerId, async () => {
const { content, tokenUsage } = await callAi(messages, {
...options,
temperature: options?.temperature ?? 0.7,
})
return { result: { content, usage: null }, tokenUsage }
})
}
async suggestSimilarQuestions(
input: SimilarQuestionInput
): Promise<SimilarQuestionResult[]> {
return withAiTracking(this.userId, "similar_question", undefined, async () => {
const count = input.count ?? 3
const userLines = [
`Question Type: ${input.questionType}`,
input.subject ? `Subject: ${input.subject}` : "",
input.knowledgePointIds?.length
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
: "",
`Generate ${count} similar questions.`,
`Original Question:\n${input.questionText}`,
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(SIMILAR_QUESTION_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.5, maxTokens: 3000 }
)
const parsed = extractJson(content)
const list =
parsed && typeof parsed === "object" && "questions" in parsed
? (parsed as Record<string, unknown>).questions
: parsed
const validated = SimilarQuestionListSchema.safeParse(list)
if (!validated.success) return { result: [] }
return { result: validated.data }
})
}
async suggestGrading(input: GradingInput): Promise<GradingSuggestion> {
return withAiTracking(this.userId, "grading_assist", undefined, async () => {
const userLines = [
`Question Type: ${input.questionType}`,
`Max Score: ${input.maxScore}`,
input.subject ? `Subject: ${input.subject}` : "",
`Question:\n${input.questionText}`,
`Student Answer:\n${input.studentAnswer}`,
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(GRADING_ASSIST_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.2, maxTokens: 1000 }
)
const parsed = extractJson(content)
const validated = GradingSuggestionSchema.safeParse(parsed)
if (!validated.success) {
return {
result: {
suggestedScore: 0,
confidence: 0,
feedback: "AI grading unavailable",
reasoning: "AI response format invalid",
},
}
}
const data = validated.data
return {
result: {
suggestedScore: Math.min(Math.max(data.suggestedScore, 0), input.maxScore),
confidence: data.confidence,
feedback: data.feedback,
reasoning: data.reasoning,
},
}
})
}
async generateLessonContent(
input: LessonContentInput
): Promise<LessonContentResult> {
return withAiTracking(this.userId, "lesson_content", undefined, async () => {
const userLines = [
`Topic: ${input.topic}`,
`Content Type: ${input.contentType}`,
input.subject ? `Subject: ${input.subject}` : "",
input.grade ? `Grade: ${input.grade}` : "",
input.additionalContext ? `Additional Context:\n${input.additionalContext}` : "",
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(LESSON_CONTENT_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.7, maxTokens: 4000 }
)
const parsed = extractJson(content)
const validated = LessonContentResultSchema.safeParse(parsed)
if (!validated.success) {
return {
result: {
title: input.topic,
content: content,
},
}
}
return { result: validated.data }
})
}
async generateQuestionVariant(
input: QuestionVariantInput
): Promise<QuestionVariantResult> {
return withAiTracking(this.userId, "question_variant", undefined, async () => {
const userLines = [
`Variant Type: ${input.variantType}`,
input.subject ? `Subject: ${input.subject}` : "",
`Original Question:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(QUESTION_VARIANT_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.6, maxTokens: 2000 }
)
const parsed = extractJson(content)
const validated = QuestionVariantResultSchema.safeParse(parsed)
if (!validated.success) {
throw new Error("AI question variant format invalid")
}
return { result: validated.data }
})
}
async analyzeWeakness(
input: WeaknessAnalysisInput
): Promise<WeaknessAnalysisResult> {
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
const userLines = [
`Student ID: ${input.studentId}`,
input.subjectId ? `Subject ID: ${input.subjectId}` : "",
`Error Items (${input.errorItems.length}):`,
JSON.stringify(
input.errorItems.map((item) => ({
questionText: item.questionText,
questionType: item.questionType,
errorCount: item.errorCount,
masteryLevel: item.masteryLevel,
})),
null,
2
),
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(WEAKNESS_ANALYSIS_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.3, maxTokens: 2000 }
)
const parsed = extractJson(content)
const validated = WeaknessAnalysisResultSchema.safeParse(parsed)
if (!validated.success) {
return {
result: {
weakAreas: [],
studyPlan: "Analysis unavailable",
recommendedResources: [],
},
}
}
return { result: validated.data }
})
}
}
/**
* 创建 AI 服务实例
*
* 在 Server Action 中调用,传入当前用户 ID。
* 测试时可替换为 mock 实现。
*/
export const createAiService = (userId: string): AiService =>
new DefaultAiService(userId)
/**
* 安全执行 AI 调用,捕获异常并返回错误消息
*/
export const safeAiCall = async <T>(
fn: () => Promise<T>
): Promise<{ ok: true; data: T } | { ok: false; message: string }> => {
try {
const data = await fn()
return { ok: true, data }
} catch (error) {
return { ok: false, message: getAiErrorMessage(error) }
}
}

View File

@@ -0,0 +1,154 @@
/**
* AI Prompt 模板
*
* 集中管理所有业务场景的 Prompt便于版本管理与调优。
* 所有 Prompt 使用英文以获得最佳模型兼容性,业务文本通过 user message 注入。
*/
// ---------------------------------------------------------------------------
// 相似题推荐
// ---------------------------------------------------------------------------
export const SIMILAR_QUESTION_SYSTEM_PROMPT = [
"You are an expert K12 education question generator.",
"Given a question, generate similar practice questions that test the same knowledge points.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "questions": [',
" {",
' "text": "question text",',
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 3,',
' "options": [{ "id": "A", "text": "option text" }],',
' "answer": "correct answer",',
' "explanation": "brief explanation"',
" }",
" ]",
"}",
"Rules:",
"- Generate 1-5 similar questions based on the count parameter.",
"- Keep the same knowledge points but vary the context and numbers.",
"- For choice questions, always include 4 options.",
"- For text questions, omit options and include the answer.",
"- Difficulty should be 1-5, matching the original.",
"Never output placeholders like ..., [...], or {...}.",
].join("\n")
// ---------------------------------------------------------------------------
// AI 辅助批改
// ---------------------------------------------------------------------------
export const GRADING_ASSIST_SYSTEM_PROMPT = [
"You are an expert K12 teacher assistant for grading subjective questions.",
"Given a question, the student's answer, and the correct answer (if available),",
"evaluate the student's answer and suggest a score with feedback.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "suggestedScore": 4,',
' "confidence": 0.85,',
' "feedback": "constructive feedback in the student\'s language",',
' "reasoning": "why this score was assigned"',
"}",
"Rules:",
"- suggestedScore must be between 0 and maxScore.",
"- confidence is between 0 and 1 (higher means more certain).",
"- feedback should be encouraging and specific.",
"- If the answer is completely wrong, suggestedScore should be 0.",
"- If the answer is partially correct, give partial credit.",
"- Consider alternative correct answers if the question allows.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 备课内容生成
// ---------------------------------------------------------------------------
export const LESSON_CONTENT_SYSTEM_PROMPT = [
"You are an expert K12 instructional designer.",
"Generate teaching content based on the given topic and context.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "title": "content title",',
' "content": "detailed content in markdown format",',
' "metadata": { "duration": "15 min", "materials": ["..."] }',
"}",
"Rules:",
"- Content should be age-appropriate for the specified grade.",
"- For 'activity' type: generate an interactive classroom activity.",
"- For 'assessment' type: generate a formative assessment.",
"- For 'question' type: generate discussion questions.",
"- For 'material' type: generate teaching material outline.",
"- Content should align with the subject curriculum.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 题目变体生成
// ---------------------------------------------------------------------------
export const QUESTION_VARIANT_SYSTEM_PROMPT = [
"You are an expert K12 question variation generator.",
"Given an original question, generate a variant based on the specified type.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "text": "variant question text",',
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 3,',
' "options": [{ "id": "A", "text": "option", "isCorrect": true }],',
' "answer": "correct answer",',
' "explanation": "brief explanation"',
"}",
"Variant types:",
"- same_knowledge_point: test the same concept with different context.",
"- different_difficulty: make it easier or harder.",
"- different_format: change the question type (e.g., choice to text).",
"Rules:",
"- For choice questions, always include 4 options with exactly one correct.",
"- Difficulty must be 1-5.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 薄弱点分析
// ---------------------------------------------------------------------------
export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
"You are an expert K12 learning analyst.",
"Analyze the student's error patterns and identify weak areas.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "weakAreas": [',
" {",
' "area": "knowledge area name",',
' "severity": "high | medium | low",',
' "suggestion": "specific improvement suggestion"',
" }",
" ],",
' "studyPlan": "personalized study plan summary",',
' "recommendedResources": ["resource 1", "resource 2"]',
"}",
"Rules:",
"- Identify 2-5 weak areas based on error frequency and mastery level.",
"- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.",
"- Suggestions should be actionable and specific.",
"- Study plan should be concise (3-5 sentences).",
"- Recommended resources can be topic names or study strategies.",
"Never output placeholders.",
].join("\n")
// ---------------------------------------------------------------------------
// 通用 JSON 提取提示词(用于修复 AI 返回的无效 JSON
// ---------------------------------------------------------------------------
export const JSON_REPAIR_SYSTEM_PROMPT = [
"You are a JSON repair engine.",
"Fix the provided invalid JSON into valid JSON only.",
"Keep the original structure and values as much as possible.",
"Do not use placeholders such as ... or [...].",
"Return JSON only without markdown.",
].join("\n")

View File

@@ -0,0 +1,83 @@
import "server-only"
import { trackEvent, type EventName } from "@/shared/lib/track-event"
export type AiUsageEvent = {
userId: string
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis"
providerId?: string
model?: string
success: boolean
durationMs: number
tokenUsage?: number
errorMessage?: string
}
const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
chat: "ai.chat",
similar_question: "ai.similar_question",
grading_assist: "ai.grading_assist",
lesson_content: "ai.lesson_content",
question_variant: "ai.question_variant",
weakness_analysis: "ai.weakness_analysis",
}
/**
* AI 使用埋点
*
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
* 非阻塞,失败不影响主流程。
*/
export const trackAiUsage = (event: AiUsageEvent): void => {
const eventName = AI_EVENT_MAP[event.capability]
void trackEvent({
event: eventName,
userId: event.userId,
targetType: event.capability,
properties: {
providerId: event.providerId,
model: event.model,
success: event.success,
durationMs: event.durationMs,
tokenUsage: event.tokenUsage,
errorMessage: event.errorMessage,
},
}).catch(() => {
// 静默失败:埋点不应影响业务流程
})
}
/**
* 测量 AI 调用耗时并自动埋点
*/
export const withAiTracking = async <T>(
userId: string,
capability: AiUsageEvent["capability"],
providerId: string | undefined,
fn: () => Promise<{ result: T; model?: string; tokenUsage?: number }>
): Promise<T> => {
const start = Date.now()
try {
const { result, model, tokenUsage } = await fn()
trackAiUsage({
userId,
capability,
providerId,
model,
success: true,
durationMs: Date.now() - start,
tokenUsage,
})
return result
} catch (error) {
trackAiUsage({
userId,
capability,
providerId,
success: false,
durationMs: Date.now() - start,
errorMessage: error instanceof Error ? error.message : String(error),
})
throw error
}
}

194
src/modules/ai/types.ts Normal file
View File

@@ -0,0 +1,194 @@
import type { ActionState } from "@/shared/types/action-state"
// ---------------------------------------------------------------------------
// 基础类型
// ---------------------------------------------------------------------------
export type AiChatRole = "system" | "user" | "assistant"
export type AiChatMessage = {
role: AiChatRole
content: string
}
export type AiChatOptions = {
providerId?: string
temperature?: number
maxTokens?: number
model?: string
}
export type AiChatResult = {
content: string
usage: unknown
}
// ---------------------------------------------------------------------------
// 业务场景类型
// ---------------------------------------------------------------------------
/** 相似题推荐输入 */
export type SimilarQuestionInput = {
questionText: string
questionType: string
subject?: string
knowledgePointIds?: string[]
count?: number
}
/** 相似题推荐结果 */
export type SimilarQuestionResult = {
text: string
type: string
difficulty?: number
options?: Array<{ id: string; text: string }>
answer?: string
explanation?: string
}
/** AI 辅助批改输入 */
export type GradingInput = {
questionText: string
questionType: string
studentAnswer: string
correctAnswer?: string
maxScore: number
subject?: string
}
/** AI 辅助批改建议 */
export type GradingSuggestion = {
suggestedScore: number
confidence: number
feedback: string
reasoning: string
}
/** 备课内容生成输入 */
export type LessonContentInput = {
topic: string
subject?: string
grade?: string
textbookId?: string
chapterId?: string
contentType: "activity" | "assessment" | "question" | "material"
additionalContext?: string
}
/** 备课内容生成结果 */
export type LessonContentResult = {
title: string
content: string
metadata?: Record<string, unknown>
}
/** 题目变体生成输入 */
export type QuestionVariantInput = {
originalQuestion: {
text: string
type: string
difficulty?: number
options?: Array<{ id: string; text: string; isCorrect?: boolean }>
answer?: string
}
subject?: string
variantType: "same_knowledge_point" | "different_difficulty" | "different_format"
}
/** 题目变体生成结果 */
export type QuestionVariantResult = {
text: string
type: string
difficulty: number
options?: Array<{ id: string; text: string; isCorrect: boolean }>
answer?: string
explanation?: string
}
/** 薄弱点分析输入 */
export type WeaknessAnalysisInput = {
studentId: string
subjectId?: string
errorItems: Array<{
questionText: string
questionType: string
knowledgePointIds?: string[]
errorCount: number
masteryLevel: number
}>
}
/** 薄弱点分析结果 */
export type WeaknessAnalysisResult = {
weakAreas: Array<{
area: string
severity: "high" | "medium" | "low"
suggestion: string
}>
studyPlan: string
recommendedResources: string[]
}
// ---------------------------------------------------------------------------
// AI 能力配置(角色驱动)
// ---------------------------------------------------------------------------
export type AiCapability =
| "chat"
| "exam-generate"
| "grading-assist"
| "lesson-content"
| "question-variant"
| "similar-question"
| "weakness-analysis"
| "study-path"
| "child-summary"
| "usage-stats"
// ---------------------------------------------------------------------------
// 服务接口
// ---------------------------------------------------------------------------
/**
* AI 服务接口(服务端)
*
* 业务模块的 data-access 或 actions 通过此接口调用 AI 能力,
* 不直接 import shared/lib/ai。
* 测试时可注入 mock 实现。
*/
export interface AiService {
chat(messages: AiChatMessage[], options?: AiChatOptions): Promise<AiChatResult>
suggestSimilarQuestions(input: SimilarQuestionInput): Promise<SimilarQuestionResult[]>
suggestGrading(input: GradingInput): Promise<GradingSuggestion>
generateLessonContent(input: LessonContentInput): Promise<LessonContentResult>
generateQuestionVariant(input: QuestionVariantInput): Promise<QuestionVariantResult>
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
}
/**
* AI 客户端服务接口
*
* 注入 Server Action 引用,客户端组件通过此接口触发 AI 操作。
* 遵循 settings 模块的依赖注入模式。
*/
export interface AiClientService {
chat: (input: {
messages: AiChatMessage[]
providerId?: string
}) => Promise<ActionState<AiChatResult>>
suggestSimilarQuestions: (
input: SimilarQuestionInput
) => Promise<ActionState<SimilarQuestionResult[]>>
suggestGrading: (input: GradingInput) => Promise<ActionState<GradingSuggestion>>
generateLessonContent: (
input: LessonContentInput
) => Promise<ActionState<LessonContentResult>>
generateQuestionVariant: (
input: QuestionVariantInput
) => Promise<ActionState<QuestionVariantResult>>
analyzeWeakness: (
input: WeaknessAnalysisInput
) => Promise<ActionState<WeaknessAnalysisResult>>
/** 预留埋点接口 */
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
}

View File

@@ -0,0 +1,353 @@
"use client"
import { useState, useTransition } from "react"
import { Archive, Trash2, FileText, Calendar, History } from "lucide-react"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { Separator } from "@/shared/components/ui/separator"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { formatDate, formatDateTime } from "@/shared/lib/utils"
import {
archiveErrorBookItemAction,
deleteErrorBookItemAction,
updateErrorBookNoteAction,
} from "../actions"
import {
ERROR_BOOK_SOURCE_LABEL,
ERROR_BOOK_SOURCE_VARIANT,
ERROR_BOOK_STATUS_LABEL,
ERROR_BOOK_STATUS_VARIANT,
REVIEW_RESULT_LABEL,
REVIEW_RESULT_VARIANT,
COMMON_ERROR_TAGS,
type ErrorBookItemDetail,
type ErrorBookItem,
} from "../types"
import { ReviewButtons } from "./review-buttons"
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
interface ErrorBookDetailDialogProps {
item: ErrorBookItemDetail | (Omit<ErrorBookItemDetail, "reviews"> & { reviews?: ErrorBookItemDetail["reviews"] })
trigger: React.ReactNode
/** 当前学生 ID用于 AI 薄弱点分析) */
studentId?: string
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析) */
errorItems?: ErrorBookItem[]
}
/**
* 从题目内容中提取纯文本(用于 AI 相似题推荐)
*
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。
*/
function extractQuestionText(content: unknown): string {
if (!content) return ""
if (typeof content === "string") return content
if (typeof content === "object" && content !== null && "text" in content) {
const textValue = (content as Record<string, unknown>).text
if (typeof textValue === "string") return textValue
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}
/**
* 将错题条目转换为 AI 薄弱点分析所需的输入格式
*/
function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{
questionText: string
questionType: string
knowledgePointIds?: string[]
errorCount: number
masteryLevel: number
}> {
return items.map((it) => ({
questionText: extractQuestionText(it.question?.content),
questionType: it.question?.type ?? "unknown",
knowledgePointIds: it.knowledgePointIds ?? undefined,
errorCount: it.reviewCount > 0 ? it.reviewCount : 1,
masteryLevel: it.masteryLevel,
}))
}
export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }: ErrorBookDetailDialogProps) {
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const [note, setNote] = useState(item.note ?? "")
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
function handleSaveNote() {
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({ itemId: item.id, note, errorTags })
)
const res = await updateErrorBookNoteAction(undefined, formData)
if (res.success) {
toast.success("笔记已保存")
} else {
toast.error(res.message ?? "保存失败")
}
})
}
function handleArchive() {
startTransition(async () => {
const formData = new FormData()
formData.append("itemId", item.id)
const res = await archiveErrorBookItemAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? "已归档")
setOpen(false)
} else {
toast.error(res.message ?? "归档失败")
}
})
}
function handleDelete() {
startTransition(async () => {
const formData = new FormData()
formData.append("itemId", item.id)
const res = await deleteErrorBookItemAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? "已删除")
setOpen(false)
} else {
toast.error(res.message ?? "删除失败")
}
})
}
function toggleTag(tag: string) {
setErrorTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
)
}
// AI 分析所需数据
const currentQuestionText = extractQuestionText(item.question?.content)
const currentQuestionType = item.question?.type
const aiErrorItems = errorItems ? mapErrorItemsForAnalysis(errorItems) : []
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 flex-wrap">
<StatusBadge
status={item.status}
variantMap={ERROR_BOOK_STATUS_VARIANT}
labelMap={ERROR_BOOK_STATUS_LABEL}
capitalize={false}
/>
<StatusBadge
status={item.sourceType}
variantMap={ERROR_BOOK_SOURCE_VARIANT}
labelMap={ERROR_BOOK_SOURCE_LABEL}
capitalize={false}
/>
{item.subjectName ? (
<Badge variant="outline">{item.subjectName}</Badge>
) : null}
</DialogTitle>
<DialogDescription className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(item.createdAt)}
</span>
<span>: {item.masteryLevel}/5</span>
<span> {item.reviewCount} </span>
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 -mx-6 px-6">
<div className="space-y-4 pb-4">
{/* 题目内容 */}
<section>
<h4 className="mb-2 text-sm font-medium"></h4>
<div className="rounded-md border bg-muted/30 p-3 text-sm">
{item.question ? (
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.question.content === "string"
? item.question.content
: JSON.stringify(item.question.content, null, 2)}
</pre>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</section>
{/* 作答对比 */}
{(item.studentAnswer !== null && item.studentAnswer !== undefined) || (item.correctAnswer !== null && item.correctAnswer !== undefined) ? (
<section className="grid gap-3 sm:grid-cols-2">
{item.studentAnswer !== null && item.studentAnswer !== undefined ? (
<div>
<h4 className="mb-2 text-sm font-medium text-rose-600 dark:text-rose-400">
</h4>
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm dark:border-rose-900 dark:bg-rose-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.studentAnswer === "string"
? item.studentAnswer
: JSON.stringify(item.studentAnswer, null, 2)}
</pre>
</div>
</div>
) : null}
{item.correctAnswer !== null && item.correctAnswer !== undefined ? (
<div>
<h4 className="mb-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
</h4>
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.correctAnswer === "string"
? item.correctAnswer
: JSON.stringify(item.correctAnswer, null, 2)}
</pre>
</div>
</div>
) : null}
</section>
) : null}
{/* AI 分析区(相似题推荐 + 薄弱点分析) */}
{studentId && currentQuestionText ? (
<section>
<h4 className="mb-2 text-sm font-medium">AI </h4>
<AiErrorBookAnalysis
studentId={studentId}
subjectId={item.subjectId ?? undefined}
currentQuestionText={currentQuestionText}
currentQuestionType={currentQuestionType}
errorItems={aiErrorItems}
/>
</section>
) : null}
{/* 复习区 */}
{item.status !== "mastered" && item.status !== "archived" ? (
<section>
<h4 className="mb-2 text-sm font-medium"></h4>
<ReviewButtons
itemId={item.id}
onReviewed={() => setOpen(false)}
/>
</section>
) : null}
{/* 笔记编辑 */}
<section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<FileText className="h-4 w-4" />
</h4>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="记录你的反思、解题思路、易错点..."
className="w-full min-h-[80px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
maxLength={2000}
/>
<div className="mt-2">
<p className="mb-1 text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => (
<Badge
key={tag}
variant={errorTags.includes(tag) ? "default" : "outline"}
className="cursor-pointer"
onClick={() => toggleTag(tag)}
>
{tag}
</Badge>
))}
</div>
</div>
<Button
size="sm"
variant="outline"
className="mt-2"
disabled={isPending}
onClick={handleSaveNote}
>
</Button>
</section>
{/* 复习历史 */}
{item.reviews && item.reviews.length > 0 ? (
<section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<History className="h-4 w-4" />
</h4>
<div className="space-y-1">
{item.reviews.slice(0, 10).map((r) => (
<div
key={r.id}
className="flex items-center justify-between rounded-md border px-3 py-1.5 text-xs"
>
<StatusBadge
status={r.result}
variantMap={REVIEW_RESULT_VARIANT}
labelMap={REVIEW_RESULT_LABEL}
capitalize={false}
/>
<span className="text-muted-foreground">
{formatDateTime(r.reviewedAt)}
</span>
</div>
))}
</div>
</section>
) : null}
<Separator />
{/* 操作按钮 */}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
disabled={isPending}
onClick={handleArchive}
>
<Archive className="h-4 w-4" data-icon="inline-start" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={isPending}
onClick={handleDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" data-icon="inline-start" />
</Button>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,47 @@
import { BookX } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import { ErrorBookItemCard } from "./error-book-item-card"
import { ErrorBookDetailDialog } from "./error-book-detail-dialog"
import type { ErrorBookItem } from "../types"
interface ErrorBookListProps {
items: ErrorBookItem[]
/** 当前学生 ID用于 AI 薄弱点分析) */
studentId?: string
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析) */
errorItems?: ErrorBookItem[]
}
export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListProps) {
if (items.length === 0) {
return (
<EmptyState
icon={BookX}
title="错题本为空"
description="完成考试或作业后,错题会自动收录到这里。你也可以手动添加错题。"
className="h-[360px] bg-card"
/>
)
}
return (
<div className="grid gap-3 md:grid-cols-2">
{items.map((item) => (
<ErrorBookItemCard key={item.id} item={item}>
<ErrorBookDetailDialog
item={item}
studentId={studentId}
errorItems={errorItems}
trigger={
<Button variant="outline" size="sm">
</Button>
}
/>
</ErrorBookItemCard>
))}
</div>
)
}

View File

@@ -149,11 +149,11 @@ export const requestAiExamStructureDraft = async (input: {
export const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => {
const text = input.sourceText.trim()
if (!text) {
return { ok: false as const, message: "请先粘贴试卷文本" }
return { ok: false as const, message: "Source text is required" }
}
const userContent = [
"请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。",
`文本内容:\n${text}`,
"Judge whether the following text is readable and resembles normal exam/question content (not garbled, random characters, or disordered text).",
`Text content:\n${text}`,
].join("\n\n")
try {
const aiResult = await createAiChatCompletion({
@@ -169,12 +169,12 @@ export const validateExamSourceText = async (input: { sourceText: string; aiProv
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
const validated = AiSourceValidationSchema.safeParse(parsed)
if (!validated.success) {
return { ok: false as const, message: "试卷文本校验失败,请重试" }
return { ok: false as const, message: "Source text validation failed, please retry" }
}
if (!validated.data.valid) {
return {
ok: false as const,
message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容",
message: validated.data.reason?.trim() || "Text appears garbled or disordered, please paste clear and complete question content",
}
}
return { ok: true as const }
@@ -245,7 +245,8 @@ export const parseQuestionDetail = async (input: {
content: q.content,
} satisfies z.infer<typeof AiQuestionSchema>
}
} catch {
} catch (error) {
console.warn("[parseQuestionDetail] Falling back to text question:", error instanceof Error ? error.message : String(error))
}
return {

View File

@@ -1,10 +1,20 @@
"use client"
import { useState } from "react"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Card } from "@/shared/components/ui/card"
import { Plus } from "lucide-react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Plus, Sparkles } from "lucide-react"
import type { Question } from "@/modules/questions/types"
import { AiQuestionVariantGenerator } from "@/modules/ai/components/ai-question-variant-generator"
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider"
type QuestionBankListProps = {
questions: Question[]
@@ -13,9 +23,32 @@ type QuestionBankListProps = {
onLoadMore?: () => void
hasMore?: boolean
isLoading?: boolean
/** 学科(用于 AI 题目变体生成) */
subject?: string
}
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
/**
* 从题目内容中提取纯文本(用于 AI 变体生成)
*
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。
*/
function extractQuestionText(content: unknown): string {
if (!content) return ""
if (typeof content === "string") return content
if (typeof content === "object" && content !== null && "text" in content) {
const textValue = (content as Record<string, unknown>).text
if (typeof textValue === "string") return textValue
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading, subject }: QuestionBankListProps) {
const aiClient = useAiClientOptional()
if (questions.length === 0 && !isLoading) {
return (
<div className="text-center py-8 text-muted-foreground">
@@ -42,6 +75,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
return { text: "" }
})()
const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown"
const questionText = extractQuestionText(q.content)
return (
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
<div className="flex-1 space-y-2">
@@ -62,9 +96,18 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
{parsedContent.text || "No content preview"}
</p>
</div>
<div className="flex items-center">
<Button
size="sm"
<div className="flex items-center gap-1">
{/* AI 题目变体生成(仅在 AiClientProvider 注入时显示) */}
{aiClient && questionText ? (
<AiVariantDialog
questionText={questionText}
questionType={q.type}
difficulty={q.difficulty}
subject={subject}
/>
) : null}
<Button
size="sm"
variant={added ? "secondary" : "default"}
disabled={added}
onClick={() => onAdd(q)}
@@ -76,13 +119,13 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
</Card>
)
})}
{hasMore && (
<div className="pt-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={onLoadMore}
<Button
variant="ghost"
size="sm"
onClick={onLoadMore}
disabled={isLoading}
className="w-full text-muted-foreground"
>
@@ -90,7 +133,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
</Button>
</div>
)}
{isLoading && questions.length === 0 && (
<div className="space-y-3">
{[1,2,3].map(i => (
@@ -101,3 +144,53 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
</div>
)
}
/**
* AI 题目变体生成对话框
*
* 独立组件,仅在用户点击时挂载,避免不必要的渲染。
*/
function AiVariantDialog({
questionText,
questionType,
difficulty,
subject,
}: {
questionText: string
questionType: string
difficulty: number
subject?: string
}) {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
title="AI variant"
>
<Sparkles className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI Variant
</DialogTitle>
</DialogHeader>
<AiQuestionVariantGenerator
originalQuestion={{
text: questionText,
type: questionType,
difficulty,
}}
subject={subject}
/>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import type { Control, UseFormReturn } from "react-hook-form"
import { useTranslations } from "next-intl"
import { Settings } from "lucide-react"
import {
FormField,
@@ -79,14 +80,20 @@ export function ExamAiGenerator({
runningPreviewTaskCount,
queuedPreviewTaskCount,
}: ExamAiGeneratorProps) {
const formatTaskTime = (value: number) => formatDateTime(new Date(value))
const t = useTranslations("ai")
const formatTaskTime = (value: number) => {
if (!Number.isFinite(value)) return ""
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ""
return formatDateTime(date)
}
return (
<Card>
<CardHeader>
<CardTitle>AI Generation</CardTitle>
<CardTitle>{t("exam.generationTitle")}</CardTitle>
<CardDescription>
Paste the exam text and generate a structured preview.
{t("exam.generationDesc")}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
@@ -96,7 +103,7 @@ export function ExamAiGenerator({
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-2">
<FormLabel>AI Provider</FormLabel>
<FormLabel>{t("provider.label")}</FormLabel>
<Dialog
open={providerDialogOpen}
onOpenChange={(open) => {
@@ -109,14 +116,14 @@ export function ExamAiGenerator({
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
<Settings className="mr-1 h-3.5 w-3.5" />
{t("provider.manage")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>AI Provider Settings</DialogTitle>
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
<DialogDescription>
Create a new provider or update existing configuration.
{t("provider.manageDescription")}
</DialogDescription>
</DialogHeader>
<AiProviderSettingsCard
@@ -136,19 +143,19 @@ export function ExamAiGenerator({
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={loadingAiProviders ? "Loading providers..." : "Select provider"} />
<SelectValue placeholder={loadingAiProviders ? t("provider.loading") : t("provider.placeholder")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{aiProviders.map((item) => (
<SelectItem key={item.id} value={item.id}>
{aiProviderLabels[item.provider] ?? item.provider} · {item.model}{item.isDefault ? " (Default)" : ""}
{aiProviderLabels[item.provider] ?? item.provider} · {item.model}{item.isDefault ? ` (${t("provider.default")})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the AI configuration for this generation.
{t("provider.description")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -156,10 +163,10 @@ export function ExamAiGenerator({
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleBackgroundPreview}>
{`加入后台队列(运行 ${runningPreviewTaskCount}/3排队 ${queuedPreviewTaskCount}`}
{t("exam.queue")} ({t("exam.queueRunning")} {runningPreviewTaskCount}/3, {t("exam.queueQueued")} {queuedPreviewTaskCount})
</Button>
<Button type="button" variant="outline" size="sm" onClick={handlePreview} disabled={previewLoading || activePreviewTaskCount > 0}>
{previewLoading ? "Generating..." : "立即预览"}
{previewLoading ? t("exam.generating") : t("exam.preview")}
</Button>
</div>
<FormField
@@ -167,16 +174,16 @@ export function ExamAiGenerator({
name="aiSourceText"
render={({ field }) => (
<FormItem>
<FormLabel>Source Exam Text</FormLabel>
<FormLabel>{t("exam.sourceText")}</FormLabel>
<FormControl>
<Textarea
placeholder="Paste the full exam text to parse into questions."
placeholder={t("exam.sourceTextPlaceholder")}
className="min-h-[200px]"
{...field}
/>
</FormControl>
<FormDescription>
AI will extract questions and structure from this text.
{t("exam.sourceTextDesc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -184,7 +191,7 @@ export function ExamAiGenerator({
/>
{previewTasks.length > 0 ? (
<div className="rounded-md border p-3 space-y-2">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("exam.backgroundTasks")}</div>
<div className="space-y-2">
{previewTasks.slice(0, 6).map((task) => (
<div key={task.id} className="rounded-md border p-2">
@@ -194,17 +201,17 @@ export function ExamAiGenerator({
</div>
<div className="mt-1 text-xs text-muted-foreground">
{task.status === "queued"
? "排队中"
? t("exam.taskStatus.queued")
: task.status === "running"
? "生成中"
? t("exam.taskStatus.running")
: task.status === "success"
? "已完成"
: `失败${task.message || "生成失败"}`}
? t("exam.taskStatus.success")
: `${t("exam.taskStatus.failed")}${task.message || ""}`}
</div>
{task.status === "success" && task.result ? (
<div className="mt-2 flex justify-end">
<Button type="button" variant="ghost" size="sm" onClick={() => handleOpenPreviewTask(task.id)}>
{t("exam.openPreview")}
</Button>
</div>
) : null}

View File

@@ -11,6 +11,7 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
import type { Question } from "@/modules/questions/types"
import type { QuestionType } from "@/modules/questions/types"
import { updateExamAction } from "@/modules/exams/actions"
import { getQuestionsAction } from "@/modules/questions/actions"
import { StructureEditor } from "./assembly/structure-editor"
@@ -19,6 +20,18 @@ import type { ExamNode } from "./assembly/selected-question-list"
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
import { createId } from "@paralleldrive/cuid2"
const QUESTION_TYPES: readonly QuestionType[] = [
"single_choice",
"multiple_choice",
"text",
"judgment",
"composite",
] as const
function isQuestionType(value: string): value is QuestionType {
return (QUESTION_TYPES as readonly string[]).includes(value)
}
type ExamAssemblyProps = {
examId: string
title: string
@@ -76,15 +89,15 @@ export function ExamAssembly(props: ExamAssemblyProps) {
startBankTransition(async () => {
const nextPage = reset ? 1 : page + 1
try {
const difficultyNum = difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter, 10)
const result = await getQuestionsAction({
q: deferredSearch,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: typeFilter === 'all' ? undefined : typeFilter as any,
difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
type: typeFilter === 'all' ? undefined : (isQuestionType(typeFilter) ? typeFilter : undefined),
difficulty: difficultyNum === undefined || Number.isNaN(difficultyNum) ? undefined : difficultyNum,
page: nextPage,
pageSize: 20
})
if (result.success && result.data) {
const questionsList = result.data.data
setBankQuestions(prev => {
@@ -97,7 +110,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
setHasMore(questionsList.length === 20)
setPage(nextPage)
}
} catch {
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Failed to load questions")
}
})
@@ -127,7 +141,9 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return calc(structure)
}, [structure])
const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
const progress = props.totalScore > 0
? Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
: 0
const addedQuestionIds = useMemo(() => {
const ids = new Set<string>()
@@ -255,12 +271,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
formData.set("examId", props.examId)
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
formData.set("structureJson", JSON.stringify(getCleanStructure()))
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Exam draft saved")
} else {
toast.error(result.message || "Save failed")
try {
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Exam draft saved")
} else {
toast.error(result.message || "Save failed")
}
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Save failed")
}
}
@@ -269,13 +290,18 @@ export function ExamAssembly(props: ExamAssemblyProps) {
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
formData.set("structureJson", JSON.stringify(getCleanStructure()))
formData.set("status", "published")
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Published exam")
router.push("/teacher/exams/all")
} else {
toast.error(result.message || "Publish failed")
try {
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Published exam")
router.push("/teacher/exams/all")
} else {
toast.error(result.message || "Publish failed")
}
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Publish failed")
}
}
@@ -396,13 +422,14 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<ScrollArea className="flex-1 p-0 bg-muted/5">
<div className="p-3">
<QuestionBankList
<QuestionBankList
questions={bankQuestions}
onAdd={handleAdd}
isAdded={(id) => addedQuestionIds.has(id)}
onLoadMore={() => fetchQuestions(false)}
hasMore={hasMore}
isLoading={isBankLoading}
subject={props.subject}
/>
</div>
</ScrollArea>

View File

@@ -27,6 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/
import { gradeHomeworkSubmissionAction } from "../actions"
import { formatDate } from "@/shared/lib/utils"
import { QuestionRenderer } from "./question-renderer"
import { AiGradingAssist } from "@/modules/ai/components/ai-grading-assist"
import {
applyAutoGrades as applyAutoGradesUtil,
extractAnswerValue,
@@ -136,16 +137,21 @@ export function HomeworkGradingView({
formData.set("submissionId", submissionId)
formData.set("answersJson", JSON.stringify(payload))
const result = await gradeHomeworkSubmissionAction(null, formData)
try {
const result = await gradeHomeworkSubmissionAction(null, formData)
if (result.success) {
toast.success(t("homework.grade.gradesSaved"))
// Optionally redirect or stay
router.refresh()
} else {
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
if (result.success) {
toast.success(t("homework.grade.gradesSaved"))
// Optionally redirect or stay
router.refresh()
} else {
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
}
} catch {
toast.error(t("homework.grade.gradesSaveFailed"))
} finally {
setIsSubmitting(false)
}
setIsSubmitting(false)
}
const handleScrollToQuestion = (id: string) => {
@@ -337,6 +343,22 @@ export function HomeworkGradingView({
/>
</div>
)}
{/* AI Grading Assist (subjective questions only) */}
{!isAutoGradable(ans) && (
<AiGradingAssist
questionText={extractQuestionText(ans.questionContent)}
questionType={ans.questionType}
studentAnswer={formatStudentAnswer(ans.studentAnswer)}
correctAnswer={getTextCorrectAnswers(ans.questionContent).join(" / ")}
maxScore={ans.maxScore}
onApplyScore={(score) => handleManualScoreChange(ans.id, String(score))}
onApplyFeedback={(feedback) => {
handleFeedbackChange(ans.id, feedback)
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: true }))
}}
/>
)}
</CardFooter>
</Card>
)
@@ -520,3 +542,21 @@ const formatStudentAnswer = (studentAnswer: unknown): string => {
if (v == null) return "—"
return JSON.stringify(v)
}
/**
* 从题目内容中提取纯文本(用于 AI 批改输入)
*
* 优先使用 `text` 字段;若不存在则回退到 JSON 字符串,
* 保证 AI 服务能拿到可读的题目描述。
*/
const extractQuestionText = (content: QuestionContent | null): string => {
if (!content) return ""
if (typeof content.text === "string" && content.text.trim().length > 0) {
return content.text
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}

View File

@@ -4,10 +4,11 @@ import { getTranslations } from "next-intl/server";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { suggestKnowledgePoints } from "./ai-suggest";
import type { ActionState, LessonPlanDocument } from "./types";
import { suggestKnowledgePointsSchema } from "./schema";
import type { ActionState } from "@/shared/types/action-state";
export async function suggestKnowledgePointsAction(input: {
doc: LessonPlanDocument;
doc: unknown;
textbookId?: string;
chapterId?: string;
}): Promise<
@@ -17,12 +18,24 @@ export async function suggestKnowledgePointsAction(input: {
> {
const t = await getTranslations("lessonPreparation");
try {
await requirePermission(Permissions.LESSON_PLAN_READ);
await requirePermission(Permissions.AI_CHAT);
const parsed = suggestKnowledgePointsSchema.safeParse(input);
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
// 并行校验两个权限点
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.AI_CHAT),
]);
// 从 unknown 安全提取 nodes 数组Zod 已校验 doc 是对象
const doc = parsed.data.doc;
const nodes = Array.isArray(doc.nodes) ? doc.nodes : [];
const suggestions = await suggestKnowledgePoints(
input.doc,
input.textbookId,
input.chapterId,
{ nodes },
parsed.data.textbookId,
parsed.data.chapterId,
);
return { success: true, data: { suggestions } };
} catch (e) {

View File

@@ -1,24 +1,43 @@
import "server-only";
import { z } from "zod";
import { env } from "@/env.mjs";
import { createAiChatCompletion } from "@/shared/lib/ai";
import {
getKnowledgePointsByTextbookId,
getKnowledgePointsByChapterId,
} from "@/modules/textbooks/data-access";
import type { LessonPlanDocument } from "./types";
const SuggestedKpSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
reason: z.string(),
});
const SuggestedKpListSchema = z.array(SuggestedKpSchema);
/** 从 unknown 节点安全提取文本(类型守卫从 unknown 收窄) */
const extractNodeText = (node: unknown): string => {
if (!node || typeof node !== "object") return ""
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查
const record = node as Record<string, unknown>
const data = record.data
if (!data || typeof data !== "object") return ""
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查
const dataRecord = data as Record<string, unknown>
const html = typeof dataRecord.html === "string" ? dataRecord.html : ""
const sourceText = typeof dataRecord.sourceText === "string" ? dataRecord.sourceText : ""
return html || sourceText || ""
}
export async function suggestKnowledgePoints(
doc: LessonPlanDocument,
doc: { nodes: unknown[] },
textbookId?: string,
chapterId?: string,
): Promise<{ id: string; name: string; reason: string }[]> {
// 1. 提取课案纯文本
const text = doc.nodes
.map((b) => {
const d = b.data as { html?: string; sourceText?: string };
return d.html ?? d.sourceText ?? "";
})
.map((b) => extractNodeText(b))
.join("\n")
.slice(0, 3000);
@@ -51,14 +70,12 @@ ${text}
// 尝试从返回内容中提取 JSON 数组
const jsonMatch = content.match(/\[[\s\S]*\]/);
if (!jsonMatch) return [];
const parsed = JSON.parse(jsonMatch[0]) as {
id: string;
name: string;
reason: string;
}[];
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = SuggestedKpListSchema.safeParse(parsed);
if (!validated.success) return [];
// 过滤掉不在候选池中的 id
const validIds = new Set(kpList.map((k) => k.id));
return parsed.filter((p) => validIds.has(p.id));
return validated.data.filter((p) => validIds.has(p.id));
} catch {
return [];
}

View File

@@ -1,11 +1,15 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Sparkles, ChevronDown, ChevronUp } from "lucide-react";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { BlockRenderer } from "../config/block-registry";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Trash2, X } from "lucide-react";
import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
interface Props {
textbookId?: string;
@@ -15,8 +19,11 @@ interface Props {
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
const t = useTranslations("lessonPreparation");
const tAi = useTranslations("ai");
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
useLessonPlanEditor();
const aiClient = useAiClientOptional();
const [showAiPanel, setShowAiPanel] = useState(false);
const node = doc.nodes.find((n) => n.id === selectedNodeId);
@@ -28,15 +35,45 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
);
}
// 正文节点不在侧边面板编辑(直接在画布上交互)
if (node.type === "textbook_content") {
return (
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
<span className="flex-1 font-title-md text-title-md">
{t("editor.textbookContent")}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => selectNode(null)}
aria-label={t("action.close")}
>
<X className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 text-sm text-on-surface-variant">
{t("editor.textbookContentEmpty")}
</div>
</div>
);
}
// 教学节点:通过类型守卫收窄为 LessonPlanNode
const lessonNode = node as import("../types").LessonPlanNode;
// 从节点标题提取主题用于 AI 内容生成
const aiTopic = lessonNode.title || t("editor.textbookContent");
return (
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
{/* 面板头部 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
<input
value={node.title}
onChange={(e) => updateNode(node.id, { title: e.target.value })}
value={lessonNode.title}
onChange={(e) => updateNode(lessonNode.id, { title: e.target.value })}
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
aria-label={node.title}
aria-label={lessonNode.title}
/>
<Button
variant="ghost"
@@ -52,17 +89,49 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
<div className="flex-1 overflow-y-auto p-3">
<LessonPlanErrorBoundary>
<BlockRenderer
type={node.type}
blockId={node.id}
data={node.data}
type={lessonNode.type}
blockId={lessonNode.id}
data={lessonNode.data}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
onUpdate={(d) => updateNode(node.id, { data: d })}
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
/>
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
<UnknownBlockHint type={node.type} t={t} />
<UnknownBlockHint type={lessonNode.type} t={t} />
</LessonPlanErrorBoundary>
{/* AI 内容生成区(可折叠) */}
{aiClient ? (
<div className="mt-4 border-t border-outline-variant pt-3">
<Button
variant="ghost"
size="sm"
className="w-full justify-between"
onClick={() => setShowAiPanel(!showAiPanel)}
aria-expanded={showAiPanel}
>
<span className="flex items-center gap-1">
<Sparkles className="w-3.5 h-3.5 text-primary" />
{tAi("lessonPrep.generateContent")}
</span>
{showAiPanel ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
</Button>
{showAiPanel ? (
<div className="mt-2">
<AiLessonContentGenerator
topic={aiTopic}
textbookId={textbookId}
chapterId={chapterId}
/>
</div>
) : null}
</div>
) : null}
</div>
{/* 底部操作 */}
@@ -71,7 +140,7 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
variant="outline"
size="sm"
className="text-error"
onClick={() => removeNode(node.id)}
onClick={() => removeNode(lessonNode.id)}
>
<Trash2 className="w-3 h-3 mr-1" aria-hidden="true" />
{t("action.delete")}

View File

@@ -0,0 +1,109 @@
{
"chat": {
"title": "AI Assistant",
"placeholder": "Ask anything...",
"inputLabel": "Message input",
"send": "Send",
"thinking": "AI is thinking...",
"maxReached": "Maximum messages reached",
"clear": "Clear conversation"
},
"provider": {
"label": "AI Provider",
"placeholder": "Select provider",
"loading": "Loading providers...",
"default": "Default",
"description": "Select the AI configuration for this operation.",
"manage": "Manage",
"manageTitle": "AI Provider Settings",
"manageDescription": "Create a new provider or update existing configuration."
},
"suggestion": {
"title": "AI Suggestions",
"generate": "Generate Suggestions",
"regenerate": "Regenerate",
"loading": "AI is thinking...",
"empty": "No suggestions available",
"error": "Failed to generate suggestions",
"loaded": "Suggestions loaded",
"selected": "Suggestion selected",
"select": "Select",
"difficulty": "Difficulty"
},
"grading": {
"title": "AI Grading Suggestion",
"suggestedScore": "Suggested Score",
"confidence": "Confidence",
"feedback": "Feedback",
"reasoning": "Reasoning",
"applyScore": "Apply Score",
"applyFeedback": "Apply Feedback",
"loading": "AI is grading...",
"error": "AI grading failed",
"notAvailable": "AI grading not available for this question type"
},
"errorBook": {
"similarQuestions": "Similar Questions",
"weaknessAnalysis": "Weakness Analysis",
"studyPlan": "Study Plan",
"recommendedResources": "Recommended Resources",
"weakAreas": "Weak Areas",
"severity": {
"high": "High",
"medium": "Medium",
"low": "Low"
}
},
"lessonPrep": {
"generateContent": "Generate Content",
"generateActivity": "Suggest Activity",
"generateAssessment": "Generate Assessment",
"generateQuestion": "Generate Discussion Question",
"loading": "Generating...",
"error": "Content generation failed"
},
"exam": {
"generate": "Generate",
"generating": "Generating...",
"preview": "Preview",
"queue": "Add to Queue",
"queueRunning": "Running",
"queueQueued": "Queued",
"backgroundTasks": "Background Tasks",
"taskStatus": {
"queued": "Queued",
"running": "Running",
"success": "Completed",
"failed": "Failed"
},
"openPreview": "Open Preview",
"sourceText": "Source Exam Text",
"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."
},
"error": {
"invalidInput": "Invalid input data",
"chatFailed": "AI request failed",
"suggestionFailed": "AI suggestion failed",
"gradingFailed": "AI grading failed",
"contentFailed": "Content generation failed",
"variantFailed": "Question variant generation failed",
"analysisFailed": "Weakness analysis failed",
"boundaryTitle": "AI Feature Error",
"boundaryDescription": "An error occurred while processing AI request. Please try again.",
"retry": "Retry",
"unauthorized": "You do not have permission to use AI features",
"providerNotConfigured": "AI provider not configured. Please contact administrator."
},
"capability": {
"chat": "AI Chat",
"examGenerate": "AI Exam Generation",
"gradingAssist": "AI Grading Assist",
"lessonContent": "AI Lesson Content",
"questionVariant": "AI Question Variant",
"similarQuestion": "AI Similar Questions",
"weaknessAnalysis": "AI Weakness Analysis"
}
}

View File

@@ -0,0 +1,109 @@
{
"chat": {
"title": "AI 助手",
"placeholder": "请输入您的问题...",
"inputLabel": "消息输入",
"send": "发送",
"thinking": "AI 正在思考...",
"maxReached": "已达到最大消息数",
"clear": "清空对话"
},
"provider": {
"label": "AI 服务商",
"placeholder": "选择服务商",
"loading": "加载服务商中...",
"default": "默认",
"description": "选择本次操作使用的 AI 配置。",
"manage": "管理",
"manageTitle": "AI 服务商设置",
"manageDescription": "新建服务商或更新已有配置。"
},
"suggestion": {
"title": "AI 建议",
"generate": "生成建议",
"regenerate": "重新生成",
"loading": "AI 思考中...",
"empty": "暂无建议",
"error": "生成建议失败",
"loaded": "建议已加载",
"selected": "已选择建议",
"select": "选择",
"difficulty": "难度"
},
"grading": {
"title": "AI 批改建议",
"suggestedScore": "建议分数",
"confidence": "置信度",
"feedback": "反馈",
"reasoning": "评分依据",
"applyScore": "应用分数",
"applyFeedback": "应用反馈",
"loading": "AI 批改中...",
"error": "AI 批改失败",
"notAvailable": "此题型不支持 AI 批改"
},
"errorBook": {
"similarQuestions": "相似题目",
"weaknessAnalysis": "薄弱点分析",
"studyPlan": "学习计划",
"recommendedResources": "推荐资源",
"weakAreas": "薄弱领域",
"severity": {
"high": "高",
"medium": "中",
"low": "低"
}
},
"lessonPrep": {
"generateContent": "生成内容",
"generateActivity": "建议活动",
"generateAssessment": "生成评估",
"generateQuestion": "生成讨论题",
"loading": "生成中...",
"error": "内容生成失败"
},
"exam": {
"generate": "生成",
"generating": "生成中...",
"preview": "预览",
"queue": "加入队列",
"queueRunning": "运行中",
"queueQueued": "排队中",
"backgroundTasks": "后台任务",
"taskStatus": {
"queued": "排队中",
"running": "生成中",
"success": "已完成",
"failed": "失败"
},
"openPreview": "打开预览",
"sourceText": "试卷原文",
"sourceTextPlaceholder": "粘贴试卷文本以解析为题目",
"sourceTextDesc": "AI 将从文本中提取题目和结构。",
"generationTitle": "AI 生成",
"generationDesc": "粘贴试卷文本并生成结构化预览。"
},
"error": {
"invalidInput": "输入数据无效",
"chatFailed": "AI 请求失败",
"suggestionFailed": "AI 建议失败",
"gradingFailed": "AI 批改失败",
"contentFailed": "内容生成失败",
"variantFailed": "题目变体生成失败",
"analysisFailed": "薄弱点分析失败",
"boundaryTitle": "AI 功能错误",
"boundaryDescription": "处理 AI 请求时发生错误,请重试。",
"retry": "重试",
"unauthorized": "您没有使用 AI 功能的权限",
"providerNotConfigured": "AI 服务商未配置,请联系管理员。"
},
"capability": {
"chat": "AI 对话",
"examGenerate": "AI 出题",
"gradingAssist": "AI 辅助批改",
"lessonContent": "AI 备课内容",
"questionVariant": "AI 题目变体",
"similarQuestion": "AI 相似题",
"weaknessAnalysis": "AI 薄弱点分析"
}
}

View File

@@ -58,6 +58,13 @@ export type EventName =
| "homework.submitted"
| "homework.graded"
| "homework.auto_save_failed"
// AI 模块监控事件
| "ai.chat"
| "ai.similar_question"
| "ai.grading_assist"
| "ai.lesson_content"
| "ai.question_variant"
| "ai.weakness_analysis"
/** 埋点事件负载 */
export interface TrackEventPayload {