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:
244
src/modules/ai/actions.ts
Normal file
244
src/modules/ai/actions.ts
Normal 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") }
|
||||
}
|
||||
}
|
||||
182
src/modules/ai/components/ai-chat-panel.tsx
Normal file
182
src/modules/ai/components/ai-chat-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
246
src/modules/ai/components/ai-error-book-analysis.tsx
Normal file
246
src/modules/ai/components/ai-error-book-analysis.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
src/modules/ai/components/ai-error-boundary.tsx
Normal file
88
src/modules/ai/components/ai-error-boundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/modules/ai/components/ai-grading-assist.tsx
Normal file
173
src/modules/ai/components/ai-grading-assist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
187
src/modules/ai/components/ai-lesson-content-generator.tsx
Normal file
187
src/modules/ai/components/ai-lesson-content-generator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
src/modules/ai/components/ai-provider-selector.tsx
Normal file
129
src/modules/ai/components/ai-provider-selector.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
208
src/modules/ai/components/ai-question-variant-generator.tsx
Normal file
208
src/modules/ai/components/ai-question-variant-generator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
src/modules/ai/components/ai-skeleton.tsx
Normal file
47
src/modules/ai/components/ai-skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
src/modules/ai/components/ai-suggestion-card.tsx
Normal file
164
src/modules/ai/components/ai-suggestion-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/modules/ai/context/ai-client-provider.tsx
Normal file
62
src/modules/ai/context/ai-client-provider.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
import type { AiClientService } from "../types"
|
||||
|
||||
/**
|
||||
* AI 客户端服务 Context
|
||||
*
|
||||
* 通过 React Context 注入 AiClientService(Server 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)
|
||||
}
|
||||
57
src/modules/ai/hooks/use-ai-chat.ts
Normal file
57
src/modules/ai/hooks/use-ai-chat.ts
Normal 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 }
|
||||
}
|
||||
72
src/modules/ai/hooks/use-ai-suggestion.ts
Normal file
72
src/modules/ai/hooks/use-ai-suggestion.ts
Normal 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
134
src/modules/ai/schema.ts
Normal 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()),
|
||||
})
|
||||
346
src/modules/ai/services/ai-service.ts
Normal file
346
src/modules/ai/services/ai-service.ts
Normal 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) }
|
||||
}
|
||||
}
|
||||
154
src/modules/ai/services/prompt-templates.ts
Normal file
154
src/modules/ai/services/prompt-templates.ts
Normal 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")
|
||||
83
src/modules/ai/services/usage-tracker.ts
Normal file
83
src/modules/ai/services/usage-tracker.ts
Normal 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
194
src/modules/ai/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user