=test_update_homework_tests_and_work_log
Some checks failed
CI / build-deploy (push) Has been cancelled

This commit is contained in:
SpecialX
2026-03-19 13:16:49 +08:00
parent eb08c0ab68
commit 99f116cb64
70 changed files with 7470 additions and 20220 deletions

View File

@@ -3,14 +3,7 @@ import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react"
@@ -43,18 +36,14 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" =
return "default"
}
const isAnswered = (status: string) => status === "submitted" || status === "graded"
export default async function StudentAssignmentsPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">Your homework assignments.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
</div>
)
@@ -62,63 +51,115 @@ export default async function StudentAssignmentsPage() {
const assignments = await getStudentHomeworkAssignments(student.id)
const hasAssignments = assignments.length > 0
const assignmentsBySubject = assignments.reduce((acc, assignment) => {
const subject = assignment.subjectName?.trim() || "Other"
const existing = acc.get(subject)
if (existing) {
existing.push(assignment)
} else {
acc.set(subject, [assignment])
}
return acc
}, new Map<string, typeof assignments>())
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) => a[0].localeCompare(b[0]))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">Your homework assignments.</p>
</div>
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
{!hasAssignments ? (
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead>Attempts</TableHead>
<TableHead>Score</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{a.attemptsUsed}/{a.maxAttempts}
</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="space-y-6">
{subjectEntries.map(([subject, items]) => {
const answeredItems = items.filter((a) => isAnswered(a.progressStatus))
const unansweredItems = items.filter((a) => !isAnswered(a.progressStatus))
return (
<div key={subject} className="space-y-3">
<div className="text-sm font-semibold text-muted-foreground">{subject}</div>
{unansweredItems.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{unansweredItems.map((a) => (
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<CardHeader className="gap-2 pb-3">
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</CardTitle>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2"></span>
<span>
Attempts {a.attemptsUsed}/{a.maxAttempts}
</span>
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">Score</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
)}
{answeredItems.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{answeredItems.map((a) => (
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<CardHeader className="gap-2 pb-3">
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</CardTitle>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2"></span>
<span>
Attempts {a.attemptsUsed}/{a.maxAttempts}
</span>
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">Score</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
)}
</div>
)})}
</div>
)}
</div>

View File

@@ -3,7 +3,7 @@ import { ExamForm } from "@/modules/exams/components/exam-form"
export default function CreateExamPage() {
return (
<div className="flex justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<div className="flex w-full justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<ExamForm />
</div>
)

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai"
export const dynamic = "force-dynamic"
const getStatusFromError = (message: string) => {
if (message === "Invalid payload" || message === "Messages are required") return 400
if (message === "AI API key missing") return 500
if (message === "Empty response") return 502
return 502
}
export async function POST(req: Request) {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
try {
const body = await req.json().catch(() => null)
const input = parseAiChatPayload(body)
const result = await createAiChatCompletion(input)
return NextResponse.json({ success: true, content: result.content, usage: result.usage })
} catch (e) {
const message = getAiErrorMessage(e)
return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) })
}
}

View File

@@ -172,7 +172,7 @@
@apply border-border;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground h-screen overflow-hidden;
font-feature-settings: "rlig" 1, "calt" 1;
}
}

View File

@@ -20,6 +20,7 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body
className={`antialiased`}
suppressHydrationWarning
>
<ThemeProvider
attribute="class"

View File

@@ -7,6 +7,9 @@ export const env = createEnv({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
NEXTAUTH_SECRET: z.string().min(1).optional(),
NEXTAUTH_URL: z.string().url().optional(),
AI_API_KEY: z.string().min(1).optional(),
AI_BASE_URL: z.string().url().optional(),
AI_MODEL: z.string().min(1).optional(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
@@ -17,6 +20,9 @@ export const env = createEnv({
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
AI_API_KEY: process.env.AI_API_KEY,
AI_BASE_URL: process.env.AI_BASE_URL,
AI_MODEL: process.env.AI_MODEL,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,

View File

@@ -5,9 +5,24 @@ import { ActionState } from "@/shared/types/action-state"
import { z } from "zod"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { exams, examQuestions, subjects, grades } from "@/shared/db/schema"
import { exams, examQuestions } from "@/shared/db/schema"
import { eq } from "drizzle-orm"
import { omitScheduledAtFromDescription } from "./data-access"
import { buildExamDescription, omitScheduledAtFromDescription, persistAiGeneratedExamDraft, persistExamDraft, resolveSubjectGradeNames } from "./data-access"
import {
AiGeneratedStructureSchema,
AiInsertQuestionSchema,
AiQuestionSchema,
generateAiCreateDraftFromSource,
generateAiPreviewData,
regenerateAiQuestionByInstruction,
} from "./ai-pipeline"
import type {
AiGeneratedQuestion,
AiGeneratedStructureNode,
AiPreviewData,
AiRewriteQuestionData,
} from "./ai-pipeline"
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
const ExamCreateSchema = z.object({
title: z.string().min(1),
@@ -27,6 +42,213 @@ const ExamCreateSchema = z.object({
.optional(),
})
const getStringValue = (formData: FormData, key: string) => {
const value = formData.get(key)
return typeof value === "string" ? value : undefined
}
const failState = <T>(message: string, errors?: Record<string, string[]>): ActionState<T> => ({
success: false,
message,
errors,
})
const successState = <T>(data: T, message?: string): ActionState<T> => ({
success: true,
message,
data,
})
const invalidFormState = <T>(
error: z.ZodError,
options?: { fallbackMessage?: string; useFirstMessage?: boolean }
): ActionState<T> => {
const errors = error.flatten().fieldErrors
const fallbackMessage = options?.fallbackMessage ?? "Invalid form data"
const useFirstMessage = options?.useFirstMessage ?? true
const messages = Object.values(errors).flatMap((items) => items ?? [])
const firstMessage = messages.find((msg): msg is string => typeof msg === "string" && msg.length > 0)
return failState<T>(useFirstMessage ? (firstMessage ?? fallbackMessage) : fallbackMessage, errors)
}
const prepareExamCreateContext = async (input: {
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
scheduledAt?: string | null
}) => {
const examId = createId()
const scheduled = input.scheduledAt || undefined
const resolvedNames = await resolveSubjectGradeNames({
subjectId: input.subject,
gradeId: input.grade,
})
const subjectName = resolvedNames.subjectName ?? input.subject
const gradeName = resolvedNames.gradeName ?? input.grade
const buildDescription = (options?: { questionCount?: number }) => buildExamDescription({
subject: subjectName,
grade: gradeName,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: scheduled,
questionCount: options?.questionCount,
})
return { examId, scheduled, subjectName, gradeName, buildDescription }
}
const loadAiDraftQuestionsAndStructure = async (input: {
rawAiQuestions: string | null
rawStructure: string | null
title: string
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
aiSourceText?: string
aiQuestionCount?: number
aiProviderId?: string
}): Promise<
| { ok: true; generated: AiGeneratedQuestion[]; structure: AiGeneratedStructureNode[] }
| { ok: false; message: string }
> => {
if (input.rawAiQuestions) {
let parsedQuestions: unknown = null
try {
parsedQuestions = JSON.parse(input.rawAiQuestions)
} catch {
return { ok: false, message: "Invalid AI preview payload" }
}
const validated = z.array(AiInsertQuestionSchema).safeParse(parsedQuestions)
if (!validated.success || validated.data.length === 0) {
return { ok: false, message: "Invalid AI preview payload" }
}
const generated = validated.data.map((q) => ({
id: q.id,
type: q.type,
difficulty: q.difficulty,
content: q.content,
score: q.score,
}))
let structure: AiGeneratedStructureNode[] = []
if (input.rawStructure) {
try {
const parsedStructure = JSON.parse(input.rawStructure)
const validatedStructure = AiGeneratedStructureSchema.safeParse(parsedStructure)
if (validatedStructure.success) {
structure = validatedStructure.data
} else {
return { ok: false, message: "Invalid preview structure" }
}
} catch {
return { ok: false, message: "Invalid preview structure" }
}
}
if (structure.length === 0) {
structure = generated.map((q) => ({
id: createId(),
type: "question",
questionId: q.id,
score: q.score,
}))
}
return { ok: true, generated, structure }
}
const sourceText = input.aiSourceText?.trim()
if (!sourceText) {
return { ok: false, message: "Please analyze and preview before creating" }
}
const aiDraft = await generateAiCreateDraftFromSource({
title: input.title,
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
questionCount: input.aiQuestionCount,
sourceText,
aiProviderId: input.aiProviderId,
})
if (!aiDraft.ok) {
return { ok: false, message: aiDraft.message }
}
return { ok: true, generated: aiDraft.generated, structure: aiDraft.structure }
}
const prepareAiPreviewRequest = async (input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
aiSourceText: string
aiQuestionCount?: number
aiProviderId?: string
}) => {
const resolvedNames = await resolveSubjectGradeNames({
subjectId: input.subject,
gradeId: input.grade,
})
const title = input.title && input.title.trim().length > 0 ? input.title : "AI Exam"
const subjectName = input.subject ? resolvedNames.subjectName ?? input.subject : undefined
const gradeName = input.grade ? resolvedNames.gradeName ?? input.grade : undefined
return {
title,
subject: subjectName,
grade: gradeName,
difficulty: input.difficulty ?? 3,
totalScore: input.totalScore ?? 100,
durationMin: input.durationMin ?? 90,
questionCount: input.aiQuestionCount,
sourceText: input.aiSourceText,
aiProviderId: input.aiProviderId,
}
}
const parseRegenerateAiQuestionInput = (
formData: FormData
):
| {
ok: true
instruction: string
aiProviderId?: string
sourceText?: string
originalQuestion: z.infer<typeof AiQuestionSchema>
}
| { ok: false; state: ActionState<AiRewriteQuestionData> } => {
const instruction = getStringValue(formData, "instruction")?.trim()
const aiProviderId = getStringValue(formData, "aiProviderId")?.trim()
const sourceText = getStringValue(formData, "sourceText")?.trim()
const questionJson = getStringValue(formData, "questionJson")
if (!instruction) {
return { ok: false, state: failState<AiRewriteQuestionData>("Please enter rewrite instruction") }
}
if (!questionJson) {
return { ok: false, state: failState<AiRewriteQuestionData>("No selected question data") }
}
try {
const parsedQuestion = JSON.parse(questionJson) as unknown
const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion)
if (!validatedQuestion.success) {
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
}
return {
ok: true,
instruction,
aiProviderId,
sourceText,
originalQuestion: validatedQuestion.data,
}
} catch {
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
}
}
export async function createExamAction(
prevState: ActionState<string> | null,
formData: FormData
@@ -34,72 +256,235 @@ export async function createExamAction(
const rawQuestions = formData.get("questionsJson") as string | null
const parsed = ExamCreateSchema.safeParse({
title: formData.get("title"),
subject: formData.get("subject"),
grade: formData.get("grade"),
difficulty: formData.get("difficulty"),
totalScore: formData.get("totalScore"),
durationMin: formData.get("durationMin"),
scheduledAt: formData.get("scheduledAt"),
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
return invalidFormState<string>(parsed.error, { useFirstMessage: false })
}
const input = parsed.data
const examId = createId()
const scheduled = input.scheduledAt || undefined
// Retrieve names for JSON description (to maintain compatibility)
const subjectRecord = await db.query.subjects.findFirst({
where: eq(subjects.id, input.subject),
})
const gradeRecord = await db.query.grades.findFirst({
where: eq(grades.id, input.grade),
})
const meta = {
subject: subjectRecord?.name ?? input.subject,
grade: gradeRecord?.name ?? input.grade,
const context = await prepareExamCreateContext({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: scheduled ?? undefined,
}
scheduledAt: input.scheduledAt,
})
const description = context.buildDescription()
try {
const user = await getCurrentUser()
await db.insert(exams).values({
id: examId,
await persistExamDraft({
examId: context.examId,
title: input.title,
description: JSON.stringify(meta),
creatorId: user?.id ?? "user_teacher_math",
subjectId: input.subject,
gradeId: input.grade,
startTime: scheduled ? new Date(scheduled) : null,
status: "draft",
scheduledAt: context.scheduled,
description,
})
} catch (error) {
console.error("Failed to create exam:", error)
return {
success: false,
message: "Database error: Failed to create exam",
}
return failState<string>("Database error: Failed to create exam")
}
revalidatePath("/teacher/exams/all")
return {
success: true,
message: "Exam created successfully.",
data: examId,
return successState(context.examId, "Exam created successfully.")
}
const AiExamCreateSchema = ExamCreateSchema.extend({
aiSourceText: z.string().optional(),
aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(),
aiProviderId: z.string().min(1).optional(),
})
const AiExamPreviewSchema = z.object({
title: z.string().optional(),
subject: z.string().optional(),
grade: z.string().optional(),
difficulty: z.coerce.number().int().min(1).max(5).optional(),
totalScore: z.coerce.number().int().min(1).optional(),
durationMin: z.coerce.number().int().min(1).optional(),
aiSourceText: z.string().min(1),
aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(),
aiProviderId: z.string().min(1).optional(),
})
export async function createAiExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawQuestions = formData.get("questionsJson") as string | null
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
const rawStructure = formData.get("structureJson") as string | null
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")
const parsed = AiExamCreateSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
? aiQuestionCountRaw
: undefined,
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
? aiProviderIdRaw
: undefined,
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error)
}
const input = parsed.data
if (!rawAiQuestions && !input.aiSourceText) {
return failState<string>("Please analyze and preview before creating")
}
const context = await prepareExamCreateContext({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
})
const user = await getCurrentUser()
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
rawAiQuestions,
rawStructure,
title: input.title,
subject: context.subjectName,
grade: context.gradeName,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
aiSourceText: input.aiSourceText,
aiQuestionCount: input.aiQuestionCount,
aiProviderId: input.aiProviderId,
})
if (!aiDraftResult.ok) {
return failState<string>(aiDraftResult.message)
}
const { generated, structure } = aiDraftResult
const questionCount = generated.length
const description = context.buildDescription({ questionCount })
try {
await persistAiGeneratedExamDraft({
examId: context.examId,
title: input.title,
creatorId: user?.id ?? "user_teacher_math",
subjectId: input.subject,
gradeId: input.grade,
scheduledAt: context.scheduled,
description,
structure,
generated,
})
} catch (error) {
console.error("Failed to create exam:", error)
return failState<string>("Database error: Failed to create exam")
}
revalidatePath("/teacher/exams/all")
return successState(context.examId, "Exam created successfully.")
}
export async function previewAiExamAction(
prevState: ActionState<AiPreviewData> | null,
formData: FormData
): Promise<ActionState<AiPreviewData>> {
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
if (!sourceText) {
return failState<AiPreviewData>("Please paste the full exam text first", {
aiSourceText: ["Please paste the full exam text first"],
})
}
const parsed = AiExamPreviewSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
aiSourceText: sourceText,
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
? aiQuestionCountRaw
: undefined,
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
? aiProviderIdRaw
: undefined,
})
if (!parsed.success) {
return invalidFormState<AiPreviewData>(parsed.error)
}
const input = parsed.data
const previewRequest = await prepareAiPreviewRequest(input)
const aiDraft = await generateAiPreviewData(previewRequest)
if (!aiDraft.ok) {
return failState<AiPreviewData>(aiDraft.message)
}
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
}
export async function regenerateAiQuestionAction(
prevState: ActionState<AiRewriteQuestionData> | null,
formData: FormData
): Promise<ActionState<AiRewriteQuestionData>> {
const parsedInput = parseRegenerateAiQuestionInput(formData)
if (!parsedInput.ok) {
return parsedInput.state
}
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
const originalDifficulty = originalQuestion.difficulty ?? 3
const originalScore = originalQuestion.score ?? 0
try {
const result = await regenerateAiQuestionByInstruction({
instruction,
originalQuestion,
sourceText,
aiProviderId,
})
if (!result.ok) {
return failState<AiRewriteQuestionData>(result.message)
}
return successState({
type: result.data.type,
difficulty: result.data.difficulty ?? originalDifficulty,
score: result.data.score ?? originalScore,
content: result.data.content,
})
} catch {
return failState<AiRewriteQuestionData>("AI question format invalid")
}
}
@@ -134,11 +519,10 @@ export async function updateExamAction(
})
if (!parsed.success) {
return {
success: false,
message: "Invalid update data",
errors: parsed.error.flatten().fieldErrors,
}
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid update data",
useFirstMessage: false,
})
}
const { examId, questions, structure, status } = parsed.data
@@ -168,19 +552,12 @@ export async function updateExamAction(
}
} catch {
return {
success: false,
message: "Database error: Failed to update exam",
}
return failState<string>("Database error: Failed to update exam")
}
revalidatePath("/teacher/exams/all")
return {
success: true,
message: "Exam updated",
data: examId,
}
return successState(examId, "Exam updated")
}
const ExamDeleteSchema = z.object({
@@ -196,11 +573,10 @@ export async function deleteExamAction(
})
if (!parsed.success) {
return {
success: false,
message: "Invalid delete data",
errors: parsed.error.flatten().fieldErrors,
}
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid delete data",
useFirstMessage: false,
})
}
const { examId } = parsed.data
@@ -208,19 +584,12 @@ export async function deleteExamAction(
try {
await db.delete(exams).where(eq(exams.id, examId))
} catch {
return {
success: false,
message: "Database error: Failed to delete exam",
}
return failState<string>("Database error: Failed to delete exam")
}
revalidatePath("/teacher/exams/all")
return {
success: true,
message: "Exam deleted",
data: examId,
}
return successState(examId, "Exam deleted")
}
const ExamDuplicateSchema = z.object({
@@ -236,11 +605,10 @@ export async function duplicateExamAction(
})
if (!parsed.success) {
return {
success: false,
message: "Invalid duplicate data",
errors: parsed.error.flatten().fieldErrors,
}
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid duplicate data",
useFirstMessage: false,
})
}
const { examId } = parsed.data
@@ -255,10 +623,7 @@ export async function duplicateExamAction(
})
if (!source) {
return {
success: false,
message: "Exam not found",
}
return failState<string>("Exam not found")
}
const newExamId = createId()
@@ -289,22 +654,17 @@ export async function duplicateExamAction(
}
})
} catch {
return {
success: false,
message: "Database error: Failed to duplicate exam",
}
return failState<string>("Database error: Failed to duplicate exam")
}
revalidatePath("/teacher/exams/all")
return {
success: true,
message: "Exam duplicated",
data: newExamId,
}
return successState(newExamId, "Exam duplicated")
}
export async function getExamPreviewAction(examId: string) {
export async function getExamPreviewAction(
examId: string
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
try {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
@@ -319,23 +679,17 @@ export async function getExamPreviewAction(examId: string) {
})
if (!exam) {
return { success: false, message: "Exam not found" }
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
}
// Extract questions from the relation
const questions = exam.questions.map(eq => eq.question)
return {
success: true,
data: {
const questions = exam.questions.map((eq) => eq.question)
return successState({
structure: exam.structure,
questions: questions
}
questions,
})
} catch (error) {
console.error(error)
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
}
} catch (error) {
console.error(error)
return { success: false, message: "Failed to load exam preview" }
}
}
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
@@ -344,16 +698,10 @@ export async function getSubjectsAction(): Promise<ActionState<{ id: string; nam
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
return {
success: true,
data: allSubjects.map((s) => ({ id: s.id, name: s.name })),
}
return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
} catch (error) {
console.error("Failed to fetch subjects:", error)
return {
success: false,
message: "Failed to load subjects",
}
return failState<{ id: string; name: string }[]>("Failed to load subjects")
}
}
@@ -363,16 +711,10 @@ export async function getGradesAction(): Promise<ActionState<{ id: string; name:
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
})
return {
success: true,
data: allGrades.map((g) => ({ id: g.id, name: g.name })),
}
return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
} catch (error) {
console.error("Failed to fetch grades:", error)
return {
success: false,
message: "Failed to load grades",
}
return failState<{ id: string; name: string }[]>("Failed to load grades")
}
}

View File

@@ -0,0 +1,912 @@
import { createId } from "@paralleldrive/cuid2"
import { z } from "zod"
import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"
import { env } from "@/env.mjs"
const AiSubQuestionSchema = z.object({
id: z.string().min(1).optional(),
text: z.string().min(1),
answer: z.string().min(1).optional(),
score: z.coerce.number().int().min(0).optional(),
})
const AiQuestionContentSchema = z.object({
text: z.string().min(1),
options: z
.array(
z.object({
id: z.string().min(1).optional(),
text: z.string().min(1),
isCorrect: z.boolean().optional(),
})
)
.optional(),
subQuestions: z.array(AiSubQuestionSchema).optional(),
})
export const AiQuestionSchema = z.object({
type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]),
difficulty: z.coerce.number().int().min(1).max(5).optional(),
score: z.coerce.number().int().min(0).optional(),
content: AiQuestionContentSchema,
})
export const AiInsertQuestionSchema = z.object({
id: z.string().min(1),
type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]),
difficulty: z.coerce.number().int().min(1).max(5),
score: z.coerce.number().int().min(0),
content: AiQuestionContentSchema.extend({
options: z
.array(
z.object({
id: z.string().min(1),
text: z.string().min(1),
isCorrect: z.boolean().optional(),
})
)
.optional(),
subQuestions: z.array(
AiSubQuestionSchema.extend({
id: z.string().min(1),
})
).optional(),
}),
})
const AiSectionSchema = z.object({
title: z.string().min(1),
questions: z.array(AiQuestionSchema).min(1),
})
const AiExamResponseSchema = z.object({
title: z.string().optional(),
questions: z.array(AiQuestionSchema).optional(),
sections: z.array(AiSectionSchema).optional(),
})
const sanitizeJsonCandidate = (value: string) => value
.replace(/\[\s*\.\.\.\s*\]/g, "[]")
.replace(/\{\s*\.\.\.\s*\}/g, "{}")
.trim()
const tryParseJson = (value: string): unknown | null => {
const sanitized = sanitizeJsonCandidate(value)
if (!sanitized) return null
try {
return JSON.parse(sanitized)
} catch {
return null
}
}
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 extractJson = (raw: string): unknown => {
const trimmed = raw.trim()
const candidates: string[] = []
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/ig)]
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")
}
const AI_JSON_REPAIR_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")
const repairJson = async (raw: string, providerId?: string) => {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId,
messages: [
{ role: "system" as const, content: AI_JSON_REPAIR_PROMPT },
{ role: "user" as const, content: raw },
],
temperature: 0,
maxTokens: 4000,
})
return extractJson(aiResult.content)
}
const parseAiResponse = async (raw: string, providerId?: string) => {
try {
return extractJson(raw)
} catch {
return repairJson(raw, providerId)
}
}
const normalizeScores = (scores: number[], totalScore: number) => {
if (scores.length === 0) return []
const sum = scores.reduce((acc, s) => acc + s, 0)
if (sum <= 0) {
const base = Math.floor(totalScore / scores.length)
const remainder = totalScore - base * scores.length
return scores.map((_, idx) => base + (idx < remainder ? 1 : 0))
}
const scaled = scores.map((s) => Math.max(0, Math.round((s / sum) * totalScore)))
let diff = totalScore - scaled.reduce((acc, s) => acc + s, 0)
let i = 0
while (diff !== 0 && i < scaled.length * 2) {
const idx = i % scaled.length
if (diff > 0) {
scaled[idx] += 1
diff -= 1
} else if (scaled[idx] > 0) {
scaled[idx] -= 1
diff += 1
}
i += 1
}
return scaled
}
const AI_EXAM_SYSTEM_PROMPT = [
"You are an exam parsing engine.",
"Parse the provided exam text and output JSON only.",
"Allowed question types: single_choice, multiple_choice, judgment, text.",
"Preserve the original order and sectioning if present.",
"Escape double quotes inside string values.",
"Output schema:",
"{",
' "sections": [',
' { "title": "Section Title", "questions": [',
' { "type": "single_choice", "difficulty": 1, "score": 5, "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ] } }',
" ] }",
" ]",
"}",
"For grouped blanks or one prompt with multiple small questions, keep one parent question and place each child item into content.subQuestions.",
'content.subQuestions item schema: { "id": "1", "text": "lǎn duò ", "answer": "懒惰", "score": 1 }',
"If you do not need sections, return { \"questions\": [] } or include real question items.",
"Never output placeholders like ..., [...], or {...}.",
"Return JSON only without markdown.",
].join("\n")
const AI_REWRITE_QUESTION_SYSTEM_PROMPT = [
"You are a question rewriting engine.",
"Rewrite exactly one question based on teacher instruction.",
"Return JSON only without markdown.",
"Allowed question types: single_choice, multiple_choice, judgment, text.",
"Output schema:",
"{",
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 1,',
' "score": 5,',
' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }',
"}",
"For judgment/text, options can be omitted. Keep subQuestions when original question has multiple child items.",
"Never output placeholders like ..., [...], or {...}.",
].join("\n")
const AiStructureQuestionSchema = z.object({
text: z.string().min(1),
score: z.coerce.number().int().min(0).optional(),
})
const AiStructureSectionSchema = z.object({
title: z.string().min(1),
questions: z.array(AiStructureQuestionSchema).min(1),
})
const AiStructureResponseSchema = z.object({
title: z.string().optional(),
sections: z.array(AiStructureSectionSchema).optional(),
questions: z.array(AiStructureQuestionSchema).optional(),
})
const AiSourceValidationSchema = z.object({
valid: z.boolean(),
reason: z.string().optional(),
})
const AI_EXAM_STRUCTURE_SYSTEM_PROMPT = [
"You are an exam splitter engine.",
"Split the provided exam text into ordered question units quickly.",
"Do not deeply analyze choices or answers in this step.",
"Keep original sectioning and question order.",
"If one stem contains multiple numbered sub-items, keep them in one question unit and include all sub-items in the same text.",
"Do not split one parent question into several child-only units.",
"Output JSON only.",
"Output schema:",
"{",
' "title": "Optional title",',
' "sections": [',
' { "title": "Section Title", "questions": [',
' { "text": "Original full question text", "score": 5 }',
" ] }",
" ]",
"}",
"If no sections, return:",
'{ "questions": [ { "text": "Original full question text", "score": 5 } ] }',
"Never output placeholders like ..., [...], or {...}.",
].join("\n")
const AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT = [
"You are an exam text validator.",
"Judge whether the input text is readable and likely a normal exam/question text.",
"Reject garbled text, random symbols, severely disordered fragments, or meaningless content.",
"Do not require strict section formatting. Focus only on readability and whether it resembles exam questions.",
"Return JSON only without markdown.",
"Output schema:",
'{ "valid": true, "reason": "short reason" }',
].join("\n")
const AI_QUESTION_DETAIL_SYSTEM_PROMPT = [
"You are an exam question detail parser.",
"Given one split question text, output one structured question JSON only.",
"Allowed question types: single_choice, multiple_choice, judgment, text.",
"For one stem with multiple child sub-items, keep one parent content.text and place child items in content.subQuestions.",
"Use exact key name content.subQuestions (camelCase).",
"Output schema:",
"{",
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 1,',
' "score": 5,',
' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }',
"}",
"For judgment/text, options can be omitted.",
"Never output placeholders like ..., [...], or {...}.",
].join("\n")
const buildAiMessages = (input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
}) => {
const userLines = [
input.title ? `Title: ${input.title}` : "",
input.subject ? `Subject: ${input.subject}` : "",
input.grade ? `Grade: ${input.grade}` : "",
typeof input.difficulty === "number" ? `Difficulty: ${input.difficulty}` : "",
typeof input.totalScore === "number" ? `Total Score: ${input.totalScore}` : "",
typeof input.durationMin === "number" ? `Duration (min): ${input.durationMin}` : "",
input.questionCount ? `Question Count: ${input.questionCount}` : "",
`Source Exam Text:\n${input.sourceText}`,
]
const userContent = userLines.filter((l) => l.length > 0).join("\n")
return [
{ role: "system" as const, content: AI_EXAM_SYSTEM_PROMPT },
{ role: "user" as const, content: userContent },
]
}
type AiDraftResult =
| { ok: true; data: z.infer<typeof AiExamResponseSchema>; rawOutput: string }
| { ok: false; message: string }
type AiStructureDraftResult =
| { ok: true; data: z.infer<typeof AiStructureResponseSchema>; rawOutput: string }
| { ok: false; message: string }
const requestAiExamDraft = async (input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
aiProviderId?: string
}): Promise<AiDraftResult> => {
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId,
messages: buildAiMessages(input),
temperature: 0.7,
maxTokens: 4000,
})
const rawOutput = aiResult.content
const data = await parseAiResponse(rawOutput, input.aiProviderId)
const validated = AiExamResponseSchema.safeParse(data)
if (!validated.success) {
return { ok: false, message: "AI response format invalid" }
}
return { ok: true, data: validated.data, rawOutput }
} catch (error) {
return { ok: false, message: getAiErrorMessage(error) }
}
}
const requestAiExamStructureDraft = async (input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
aiProviderId?: string
}): Promise<AiStructureDraftResult> => {
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId,
messages: [
{ role: "system" as const, content: AI_EXAM_STRUCTURE_SYSTEM_PROMPT },
{ role: "user" as const, content: buildAiMessages(input)[1].content },
],
temperature: 0.2,
maxTokens: 4000,
})
const rawOutput = aiResult.content
const data = await parseAiResponse(rawOutput, input.aiProviderId)
const validated = AiStructureResponseSchema.safeParse(data)
if (!validated.success) {
return { ok: false, message: "AI response format invalid" }
}
return { ok: true, data: validated.data, rawOutput }
} catch (error) {
return { ok: false, message: getAiErrorMessage(error) }
}
}
type SplitQuestionItem = {
sectionIndex: number | null
sectionTitle?: string
text: string
score?: number
}
const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => {
const text = input.sourceText.trim()
if (!text) {
return { ok: false as const, message: "请先粘贴试卷文本" }
}
const userContent = [
"请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。",
`文本内容:\n${text}`,
].join("\n\n")
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId,
messages: [
{ role: "system" as const, content: AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT },
{ role: "user" as const, content: userContent },
],
temperature: 0,
maxTokens: 300,
})
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
const validated = AiSourceValidationSchema.safeParse(parsed)
if (!validated.success) {
return { ok: false as const, message: "试卷文本校验失败,请重试" }
}
if (!validated.data.valid) {
return {
ok: false as const,
message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容",
}
}
return { ok: true as const }
} catch (error) {
return { ok: false as const, message: getAiErrorMessage(error) }
}
}
const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>) => {
const hasSections = Array.isArray(draft.sections) && draft.sections.length > 0
if (!hasSections) {
return (draft.questions ?? []).map((q) => ({
sectionIndex: null,
sectionTitle: undefined,
text: q.text,
score: q.score,
} satisfies SplitQuestionItem))
}
const rows: SplitQuestionItem[] = []
draft.sections!.forEach((section, sectionIndex) => {
section.questions.forEach((q) => {
rows.push({
sectionIndex,
sectionTitle: section.title,
text: q.text,
score: q.score,
})
})
})
return rows
}
const mapWithConcurrency = async <T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>
) => {
const results = new Array<R>(items.length)
let cursor = 0
const runWorker = async () => {
while (cursor < items.length) {
const index = cursor
cursor += 1
results[index] = await worker(items[index], index)
}
}
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker())
await Promise.all(workers)
return results
}
const parseQuestionDetail = async (input: {
item: SplitQuestionItem
subject?: string
grade?: string
difficulty: number
aiProviderId?: string
}) => {
const normalizeQuestionCandidate = (value: unknown): unknown => {
if (!value || typeof value !== "object") return value
const record = value as Record<string, unknown>
const contentRaw = record.content
if (!contentRaw || typeof contentRaw !== "object") return value
const content = contentRaw as Record<string, unknown>
const normalizedSubQuestions = Array.isArray(content.subQuestions)
? content.subQuestions
: Array.isArray(content.subquestions)
? content.subquestions
: Array.isArray(content.sub_questions)
? content.sub_questions
: undefined
if (!normalizedSubQuestions) return value
return {
...record,
content: {
...content,
subQuestions: normalizedSubQuestions,
},
}
}
const userContent = [
input.subject ? `Subject: ${input.subject}` : "",
input.grade ? `Grade: ${input.grade}` : "",
`Question Text:\n${input.item.text}`,
].filter((line) => line.length > 0).join("\n\n")
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId,
messages: [
{ role: "system" as const, content: AI_QUESTION_DETAIL_SYSTEM_PROMPT },
{ role: "user" as const, content: userContent },
],
temperature: 0.4,
maxTokens: 1200,
})
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
const candidate = parsed && typeof parsed === "object" && "question" in parsed
? (parsed as { question: unknown }).question
: parsed
const validated = AiQuestionSchema.safeParse(normalizeQuestionCandidate(candidate))
if (validated.success) {
const q = validated.data
return {
type: q.type,
difficulty: q.difficulty ?? input.difficulty,
score: q.score ?? input.item.score ?? 0,
content: q.content,
} satisfies z.infer<typeof AiQuestionSchema>
}
} catch {
}
return {
type: "text",
difficulty: input.difficulty,
score: input.item.score ?? 0,
content: { text: input.item.text },
} satisfies z.infer<typeof AiQuestionSchema>
}
const buildQuestionContent = (q: z.infer<typeof AiQuestionSchema>) => {
const base = { text: q.content.text }
const subQuestions = Array.isArray(q.content.subQuestions)
? q.content.subQuestions.map((item, index) => ({
id: item.id ?? String(index + 1),
text: item.text,
answer: item.answer,
score: item.score,
}))
: []
if (q.type === "single_choice" || q.type === "multiple_choice") {
const options = (q.content.options ?? []).map((opt, idx) => ({
id: opt.id ?? String.fromCharCode(65 + idx),
text: opt.text,
isCorrect: opt.isCorrect ?? false,
}))
if (options.length > 0 && subQuestions.length > 0) return { ...base, options, subQuestions }
if (options.length > 0) return { ...base, options }
if (subQuestions.length > 0) return { ...base, subQuestions }
return base
}
if (subQuestions.length > 0) return { ...base, subQuestions }
return base
}
type AiPreviewQuestion = {
id: string
type: z.infer<typeof AiQuestionSchema>["type"]
difficulty: number
score: number
content: ReturnType<typeof buildQuestionContent>
}
export type AiPreviewData = {
title: string
rawOutput?: string
sections?: Array<{
id: string
title: string
questions: AiPreviewQuestion[]
}>
questions?: AiPreviewQuestion[]
}
export type AiRewriteQuestionData = {
type: z.infer<typeof AiQuestionSchema>["type"]
difficulty: number
score: number
content: ReturnType<typeof buildQuestionContent>
}
export type AiGeneratedQuestion = {
id: string
type: z.infer<typeof AiQuestionSchema>["type"]
difficulty: number
score: number
content: ReturnType<typeof buildQuestionContent>
}
export type AiGeneratedStructureNode = {
id: string
type: "group" | "question"
title?: string
questionId?: string
score?: number
children?: AiGeneratedStructureNode[]
}
export const AiGeneratedStructureNodeSchema: z.ZodType<AiGeneratedStructureNode> = z.lazy(() => z.object({
id: z.string().min(1),
type: z.enum(["group", "question"]),
title: z.string().optional(),
questionId: z.string().optional(),
score: z.coerce.number().int().min(0).optional(),
children: z.array(AiGeneratedStructureNodeSchema).optional(),
}))
export const AiGeneratedStructureSchema = z.array(AiGeneratedStructureNodeSchema)
const buildPreviewPayload = (
aiParsed: z.infer<typeof AiExamResponseSchema>,
input: {
title: string
difficulty: number
totalScore: number
questionCount?: number
}
): AiPreviewData => {
const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0
const baseQuestions = hasSections ? aiParsed.sections!.flatMap((s) => s.questions) : aiParsed.questions ?? []
const limit = input.questionCount
let sections = aiParsed.sections
let flatQuestions = baseQuestions
if (typeof limit === "number" && limit > 0) {
if (hasSections) {
let remaining = limit
sections = aiParsed.sections!.map((s) => {
if (remaining <= 0) return { ...s, questions: [] }
const sliced = s.questions.slice(0, remaining)
remaining -= sliced.length
return { ...s, questions: sliced }
}).filter((s) => s.questions.length > 0)
flatQuestions = sections.flatMap((s) => s.questions)
} else {
flatQuestions = baseQuestions.slice(0, limit)
}
}
const scores = normalizeScores(
flatQuestions.map((q) => q.score ?? 0),
input.totalScore
)
let scoreIndex = 0
const toPreviewQuestion = (q: z.infer<typeof AiQuestionSchema>): AiPreviewQuestion => ({
id: createId(),
type: q.type,
difficulty: q.difficulty ?? input.difficulty,
score: scores[scoreIndex++] ?? 0,
content: buildQuestionContent(q),
})
if (hasSections && sections && sections.length > 0) {
return {
title: aiParsed.title ?? input.title,
sections: sections.map((section) => ({
id: createId(),
title: section.title,
questions: section.questions.map((q) => toPreviewQuestion(q)),
})),
}
}
return {
title: aiParsed.title ?? input.title,
questions: flatQuestions.map((q) => toPreviewQuestion(q)),
}
}
const previewToDraft = (preview: AiPreviewData) => {
const generated: AiGeneratedQuestion[] = []
const structure: AiGeneratedStructureNode[] = []
if (Array.isArray(preview.sections) && preview.sections.length > 0) {
for (const section of preview.sections) {
const children: AiGeneratedStructureNode[] = []
for (const question of section.questions) {
generated.push({
id: question.id,
type: question.type,
difficulty: question.difficulty,
score: question.score,
content: question.content,
})
children.push({
id: createId(),
type: "question",
questionId: question.id,
score: question.score,
})
}
structure.push({
id: section.id || createId(),
type: "group",
title: section.title,
children,
})
}
return { generated, structure }
}
for (const question of preview.questions ?? []) {
generated.push({
id: question.id,
type: question.type,
difficulty: question.difficulty,
score: question.score,
content: question.content,
})
structure.push({
id: createId(),
type: "question",
questionId: question.id,
score: question.score,
})
}
return { generated, structure }
}
export async function generateAiPreviewData(input: {
title: string
subject?: string
grade?: string
difficulty: number
totalScore: number
durationMin: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
const sourceValidation = await validateExamSourceText({
sourceText: input.sourceText,
aiProviderId: input.aiProviderId,
})
if (!sourceValidation.ok) {
return { ok: false as const, message: sourceValidation.message }
}
const structureDraft = await requestAiExamStructureDraft(input)
if (!structureDraft.ok) return structureDraft
const splitItems = splitStructureItems(structureDraft.data)
const limitedItems = typeof input.questionCount === "number" && input.questionCount > 0
? splitItems.slice(0, input.questionCount)
: splitItems
if (limitedItems.length === 0) {
return { ok: false as const, message: "AI returned no questions" }
}
const detailedQuestions = await mapWithConcurrency(limitedItems, 6, (item) => parseQuestionDetail({
item,
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
aiProviderId: input.aiProviderId,
}))
const hasSectionStructure = limitedItems.some((item) => item.sectionIndex !== null)
const aiParsed: z.infer<typeof AiExamResponseSchema> = hasSectionStructure
? {
title: structureDraft.data.title ?? input.title,
sections: (() => {
const sectionMap = new Map<number, { title: string; questions: z.infer<typeof AiQuestionSchema>[] }>()
limitedItems.forEach((item, index) => {
if (item.sectionIndex === null) return
const existed = sectionMap.get(item.sectionIndex)
const question = detailedQuestions[index]
if (existed) {
existed.questions.push(question)
return
}
sectionMap.set(item.sectionIndex, {
title: item.sectionTitle || `Section ${item.sectionIndex + 1}`,
questions: [question],
})
})
return Array.from(sectionMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([, section]) => section)
})(),
questions: undefined,
}
: {
title: structureDraft.data.title ?? input.title,
questions: detailedQuestions,
sections: undefined,
}
const payload = buildPreviewPayload(aiParsed, input)
return {
ok: true as const,
data: payload,
rawOutput: structureDraft.rawOutput,
}
}
export async function generateAiCreateDraftFromSource(input: {
title: string
subject?: string
grade?: string
difficulty: number
totalScore: number
durationMin: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
const preview = await generateAiPreviewData(input)
if (!preview.ok) {
return preview
}
const draft = previewToDraft(preview.data)
return {
ok: true as const,
generated: draft.generated,
structure: draft.structure,
rawOutput: preview.rawOutput,
}
}
export async function regenerateAiQuestionByInstruction(input: {
instruction: string
originalQuestion: z.infer<typeof AiQuestionSchema>
sourceText?: string
aiProviderId?: string
}) {
const originalDifficulty = input.originalQuestion.difficulty ?? 3
const originalScore = input.originalQuestion.score ?? 0
const contextLines = [
`Instruction:\n${input.instruction}`,
`Original Question JSON:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
input.sourceText ? `Source Exam Text:\n${input.sourceText}` : "",
]
const userContent = contextLines.filter((line) => line.length > 0).join("\n\n")
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId && input.aiProviderId.length > 0 ? input.aiProviderId : undefined,
messages: [
{ role: "system" as const, content: AI_REWRITE_QUESTION_SYSTEM_PROMPT },
{ role: "user" as const, content: userContent },
],
temperature: 0.7,
maxTokens: 2000,
})
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
const candidate = parsed && typeof parsed === "object" && "question" in parsed
? (parsed as { question: unknown }).question
: parsed
const validated = AiQuestionSchema.safeParse(candidate)
if (!validated.success) {
return { ok: false as const, message: "AI question format invalid" }
}
const question = validated.data
return {
ok: true as const,
data: {
type: question.type,
difficulty: question.difficulty ?? originalDifficulty,
score: question.score ?? originalScore,
content: buildQuestionContent(question),
} satisfies AiRewriteQuestionData,
}
} catch (error) {
return { ok: false as const, message: getAiErrorMessage(error) }
}
}
export async function generateAiExamDraft(input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
return requestAiExamDraft(input)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import { db } from "@/shared/db"
import { exams } from "@/shared/db/schema"
import { exams, examQuestions, questions, subjects, grades } from "@/shared/db/schema"
import { eq, desc, like, and, or } from "drizzle-orm"
import { cache } from "react"
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
export type GetExamsParams = {
q?: string
@@ -158,3 +159,139 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
return description || "{}"
}
}
export const resolveSubjectGradeNames = async (input: {
subjectId?: string
gradeId?: string
}) => {
const [subjectRecord, gradeRecord] = await Promise.all([
input.subjectId
? db.query.subjects.findFirst({
where: eq(subjects.id, input.subjectId),
})
: Promise.resolve(null),
input.gradeId
? db.query.grades.findFirst({
where: eq(grades.id, input.gradeId),
})
: Promise.resolve(null),
])
return {
subjectName: subjectRecord?.name,
gradeName: gradeRecord?.name,
}
}
export const buildExamDescription = (input: {
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
scheduledAt?: string
questionCount?: number
}) => JSON.stringify({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
questionCount: input.questionCount,
})
export const persistExamDraft = async (input: {
examId: string
title: string
creatorId: string
subjectId: string
gradeId: string
scheduledAt?: string
description: string
}) => {
await db.insert(exams).values({
id: input.examId,
title: input.title,
description: input.description,
creatorId: input.creatorId,
subjectId: input.subjectId,
gradeId: input.gradeId,
startTime: input.scheduledAt ? new Date(input.scheduledAt) : null,
status: "draft",
})
}
const buildOrderedQuestionsFromStructure = (
structure: AiGeneratedStructureNode[],
generated: AiGeneratedQuestion[]
) => {
const questionById = new Map(generated.map((q) => [q.id, q] as const))
const orderedQuestions: Array<{ id: string; score: number }> = []
const collectOrder = (nodes: AiGeneratedStructureNode[]) => {
for (const node of nodes) {
if (node.type === "question" && typeof node.questionId === "string" && node.questionId) {
const score = typeof node.score === "number" ? node.score : questionById.get(node.questionId)?.score ?? 0
orderedQuestions.push({ id: node.questionId, score })
continue
}
if (node.type === "group" && Array.isArray(node.children) && node.children.length > 0) {
collectOrder(node.children)
}
}
}
collectOrder(structure)
if (orderedQuestions.length === 0) {
return generated.map((q) => ({ id: q.id, score: q.score ?? 0 }))
}
return orderedQuestions
}
export const persistAiGeneratedExamDraft = async (input: {
examId: string
title: string
creatorId: string
subjectId: string
gradeId: string
scheduledAt?: string
description: string
structure: AiGeneratedStructureNode[]
generated: AiGeneratedQuestion[]
}) => {
const orderedQuestions = buildOrderedQuestionsFromStructure(input.structure, input.generated)
await db.transaction(async (tx) => {
await tx.insert(exams).values({
id: input.examId,
title: input.title,
description: input.description,
creatorId: input.creatorId,
subjectId: input.subjectId,
gradeId: input.gradeId,
startTime: input.scheduledAt ? new Date(input.scheduledAt) : null,
status: "draft",
structure: input.structure,
})
if (input.generated.length > 0) {
await tx.insert(questions).values(
input.generated.map((q) => ({
id: q.id,
content: q.content,
type: q.type,
difficulty: q.difficulty,
authorId: input.creatorId,
}))
)
}
if (orderedQuestions.length > 0) {
await tx.insert(examQuestions).values(
orderedQuestions.map((q, idx) => ({
examId: input.examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
}
})
}

View File

@@ -7,12 +7,14 @@ import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classEnrollments,
exams,
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
roles,
subjects,
users,
usersToRoles,
} from "@/shared/db/schema"
@@ -584,14 +586,27 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, studentId))
const assignments = await db.query.homeworkAssignments.findMany({
where: and(
eq(homeworkAssignments.status, "published"),
inArray(homeworkAssignments.id, targetAssignmentIds),
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
),
orderBy: [desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt)],
})
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
dueAt: homeworkAssignments.dueAt,
availableAt: homeworkAssignments.availableAt,
maxAttempts: homeworkAssignments.maxAttempts,
createdAt: homeworkAssignments.createdAt,
subjectName: subjects.name,
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(
and(
eq(homeworkAssignments.status, "published"),
inArray(homeworkAssignments.id, targetAssignmentIds),
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
)
)
.orderBy(desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt))
if (assignments.length === 0) return []
@@ -620,6 +635,7 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
const item: StudentHomeworkAssignmentListItem = {
id: a.id,
title: a.title,
subjectName: a.subjectName ?? null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
maxAttempts: a.maxAttempts,

View File

@@ -82,6 +82,7 @@ export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "sub
export interface StudentHomeworkAssignmentListItem {
id: string
title: string
subjectName?: string | null
dueAt: string | null
availableAt: string | null
maxAttempts: number

View File

@@ -64,7 +64,7 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
<SidebarContext.Provider
value={{ expanded, setExpanded, isMobile, toggleSidebar }}
>
<div className="flex min-h-screen flex-col md:flex-row bg-background">
<div className="flex h-screen overflow-hidden w-full flex-col md:flex-row bg-background">
{/* Mobile Trigger & Sheet */}
{isMobile && (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>

View File

@@ -0,0 +1,204 @@
"use server"
import { z } from "zod"
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { count, desc, eq } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { aiProviders } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const AiProviderFormSchema = z.object({
id: z.string().optional(),
provider: ProviderSchema,
baseUrl: z.string().url().optional().or(z.literal("")),
model: z.string().min(1),
apiKey: z.string().min(1).optional(),
isDefault: z.boolean().optional(),
})
const AiProviderTestSchema = AiProviderFormSchema.extend({
apiKey: z.string().optional(),
}).superRefine((data, ctx) => {
if (!data.apiKey?.trim() && !data.id?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["apiKey"],
message: "API key is required",
})
}
})
export type AiProviderSummary = {
id: string
provider: z.infer<typeof ProviderSchema>
baseUrl: string | null
model: string
apiKeyLast4: string | null
isDefault: boolean
updatedAt: Date
}
const ensureUser = async () => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) throw new Error("Unauthorized")
return { id: userId }
}
const normalizeBaseUrl = (value: string | undefined) => {
const raw = String(value ?? "").trim()
if (!raw.length) return null
const trimmed = raw.replace(/\/+$/, "")
return trimmed
.replace(/\/v1\/chat\/completions$/i, "")
.replace(/\/chat\/completions$/i, "")
}
export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
await ensureUser()
const rows = await db
.select({
id: aiProviders.id,
provider: aiProviders.provider,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
updatedAt: aiProviders.updatedAt,
})
.from(aiProviders)
.orderBy(desc(aiProviders.updatedAt))
return rows
}
export async function upsertAiProviderAction(
data: z.infer<typeof AiProviderFormSchema>
): Promise<ActionState<string>> {
try {
const user = await ensureUser()
const parsed = AiProviderFormSchema.safeParse(data)
if (!parsed.success) {
return { success: false, message: "Invalid form data" }
}
const payload = parsed.data
const baseUrl = normalizeBaseUrl(payload.baseUrl)
if (payload.provider !== "openai" && !baseUrl) {
return { success: false, message: "Base URL is required for this provider" }
}
const [defaultRow] = await db
.select({ value: count() })
.from(aiProviders)
.where(eq(aiProviders.isDefault, true))
const defaultCount = Number(defaultRow?.value ?? 0)
const hasDefault = defaultCount > 0
if (payload.id) {
const id = payload.id
const [existing] = await db
.select({
id: aiProviders.id,
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
})
.from(aiProviders)
.where(eq(aiProviders.id, id))
.limit(1)
if (!existing) return { success: false, message: "AI provider not found" }
const nextKey = payload.apiKey?.trim()
const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted
const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4
const nextIsDefault =
payload.isDefault === false && existing.isDefault && defaultCount <= 1 ? true : payload.isDefault ?? existing.isDefault
await db.transaction(async (tx) => {
if (payload.isDefault) {
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
}
await tx
.update(aiProviders)
.set({
provider: payload.provider,
baseUrl,
model: payload.model,
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: nextIsDefault,
updatedBy: user.id,
})
.where(eq(aiProviders.id, id))
})
revalidatePath("/settings")
return { success: true, message: "AI provider updated", data: id }
}
if (!payload.apiKey) {
return { success: false, message: "API key is required" }
}
const id = createId()
const encrypted = encryptAiApiKey(payload.apiKey.trim())
const last4 = payload.apiKey.trim().slice(-4)
const makeDefault = payload.isDefault ?? !hasDefault
await db.transaction(async (tx) => {
if (makeDefault) {
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
}
await tx.insert(aiProviders).values({
id,
provider: payload.provider,
baseUrl,
model: payload.model,
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: makeDefault,
createdBy: user.id,
updatedBy: user.id,
})
})
revalidatePath("/settings")
return { success: true, message: "AI provider created", data: id }
} catch {
return { success: false, message: "Failed to save AI provider" }
}
}
export async function testAiProviderAction(
data: z.infer<typeof AiProviderTestSchema>
): Promise<ActionState<null>> {
try {
await ensureUser()
const parsed = AiProviderTestSchema.safeParse(data)
if (!parsed.success) {
return { success: false, message: "Invalid form data" }
}
const payload = parsed.data
const baseUrl = normalizeBaseUrl(payload.baseUrl)
if (payload.provider !== "openai" && !baseUrl) {
return { success: false, message: "Base URL is required for this provider" }
}
const model = payload.model.trim()
const apiKey = payload.apiKey?.trim()
if (apiKey) {
await testAiProviderConfig({ apiKey, baseUrl: baseUrl ?? undefined, model })
} else if (payload.id) {
await testAiProviderById(payload.id, { baseUrl: baseUrl ?? undefined, model })
}
return { success: true, message: "AI connection ok", data: null }
} catch (error) {
return { success: false, message: getAiErrorMessage(error) }
}
}

View File

@@ -0,0 +1,405 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { Loader2, Save, Sparkles } from "lucide-react"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const AiProviderFormSchema = z.object({
id: z.string().optional(),
provider: ProviderSchema,
baseUrl: z.string().optional(),
model: z.string().min(1, "Model is required"),
apiKey: z.string().optional(),
isDefault: z.boolean().optional(),
})
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
const providerLabels: Record<z.infer<typeof ProviderSchema>, string> = {
zhipu: "智谱",
openai: "OpenAI",
gemini: "Gemini",
custom: "Custom",
}
const NEW_PROVIDER_VALUE = "__new__"
export function AiProviderSettingsCard({
onProvidersChanged,
initialMode = "first",
}: {
onProvidersChanged?: (rows: AiProviderSummary[]) => void
initialMode?: "new" | "first"
}) {
const [isPending, startTransition] = useTransition()
const [providers, setProviders] = useState<AiProviderSummary[]>([])
const [selectedId, setSelectedId] = useState<string>("")
const [testStatus, setTestStatus] = useState<"idle" | "testing" | "passed" | "failed">("idle")
const [lastTestedSignature, setLastTestedSignature] = useState<string>("")
const loadedRef = useRef(false)
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(AiProviderFormSchema),
defaultValues: {
id: "",
provider: "openai",
baseUrl: "",
model: "",
apiKey: "",
isDefault: false,
},
})
const selectedProvider = useMemo(
() => providers.find((item) => item.id === selectedId) ?? null,
[providers, selectedId]
)
const buildSignature = useCallback((values: AiProviderFormValues) => {
return JSON.stringify({
provider: values.provider,
baseUrl: values.baseUrl?.trim() || "",
model: values.model.trim(),
apiKey: values.apiKey?.trim() || "",
})
}, [])
const resetToNew = useCallback(() => {
setSelectedId("")
setTestStatus("idle")
setLastTestedSignature("")
form.reset({
id: "",
provider: "openai",
baseUrl: "",
model: "",
apiKey: "",
isDefault: false,
})
}, [form])
useEffect(() => {
if (loadedRef.current) return
loadedRef.current = true
startTransition(async () => {
try {
const rows = await getAiProviderSummaries()
setProviders(rows)
onProvidersChanged?.(rows)
if (initialMode === "new") {
resetToNew()
return
}
if (rows.length > 0 && !selectedId) {
const next = rows[0]
setSelectedId(next.id)
form.reset({
id: next.id,
provider: next.provider,
baseUrl: next.baseUrl ?? "",
model: next.model,
apiKey: "",
isDefault: next.isDefault,
})
}
} catch {
toast.error("Failed to load AI providers")
}
})
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew])
const handleSelectChange = (value: string) => {
if (value === NEW_PROVIDER_VALUE) {
resetToNew()
return
}
setSelectedId(value)
setTestStatus("idle")
setLastTestedSignature("")
const next = providers.find((item) => item.id === value)
if (!next) return
form.reset({
id: next.id,
provider: next.provider,
baseUrl: next.baseUrl ?? "",
model: next.model,
apiKey: "",
isDefault: next.isDefault,
})
}
useEffect(() => {
const subscription = form.watch(() => {
if (!lastTestedSignature) return
const currentSignature = buildSignature(form.getValues())
if (currentSignature !== lastTestedSignature) {
setTestStatus("idle")
}
})
return () => subscription.unsubscribe()
}, [form, buildSignature, lastTestedSignature])
const handleTest = () => {
const values = form.getValues()
const apiKey = values.apiKey?.trim()
if (!apiKey && !values.id?.trim()) {
toast.error("Please enter API key to test")
return
}
setTestStatus("testing")
startTransition(async () => {
const payload = {
id: values.id?.trim() || undefined,
provider: values.provider,
baseUrl: values.baseUrl?.trim() || undefined,
model: values.model.trim(),
apiKey: apiKey || undefined,
isDefault: values.isDefault ?? false,
}
const result = await testAiProviderAction(payload)
if (result.success) {
setTestStatus("passed")
setLastTestedSignature(buildSignature(values))
toast.success(result.message ?? "Test passed")
} else {
setTestStatus("failed")
toast.error(result.message ?? "Test failed")
}
})
}
const onSubmit = (values: AiProviderFormValues) => {
const signature = buildSignature(values)
if (testStatus !== "passed" || signature !== lastTestedSignature) {
toast.error("Please test the configuration before saving")
return
}
startTransition(async () => {
const payload = {
id: values.id?.trim() || undefined,
provider: values.provider,
baseUrl: values.baseUrl?.trim() || undefined,
model: values.model.trim(),
apiKey: values.apiKey?.trim() || undefined,
isDefault: values.isDefault ?? false,
}
const result = await upsertAiProviderAction(payload)
if (result.success) {
toast.success(result.message ?? "Saved")
setTestStatus("idle")
setLastTestedSignature("")
const rows = await getAiProviderSummaries()
setProviders(rows)
onProvidersChanged?.(rows)
const nextId = result.data ?? payload.id ?? ""
setSelectedId(nextId)
const next = rows.find((item) => item.id === nextId)
if (next) {
form.reset({
id: next.id,
provider: next.provider,
baseUrl: next.baseUrl ?? "",
model: next.model,
apiKey: "",
isDefault: next.isDefault,
})
}
} else {
toast.error(result.message ?? "Failed to save")
}
})
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
AI Providers
</CardTitle>
<CardDescription>Manage AI vendors and default model configuration.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<FormLabel>Existing Providers</FormLabel>
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
<SelectTrigger>
<SelectValue placeholder="Create new or select existing" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_PROVIDER_VALUE}>Create new</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{providerLabels[item.provider]} · {item.model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<FormLabel>Key Status</FormLabel>
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
{selectedProvider?.apiKeyLast4
? `Stored • ****${selectedProvider.apiKeyLast4}`
: "No key stored"}
</div>
</div>
</div>
<Form {...form}>
<div className="grid gap-6">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} disabled />
</FormControl>
<FormDescription>Auto-generated for each provider.</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="zhipu"></SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://open.bigmodel.cn/api/paas/v4" />
</FormControl>
<FormDescription> /chat/completions</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<FormControl>
<Input {...field} placeholder="gpt-4o-mini" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder="Paste new key to replace" />
</FormControl>
<FormDescription> Key</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="isDefault"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<Checkbox checked={!!field.value} onCheckedChange={(value) => field.onChange(value === true)} />
</FormControl>
<FormLabel></FormLabel>
</FormItem>
)}
/>
<CardFooter className="flex justify-between border-t px-0 pt-4">
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
{testStatus === "testing" ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Test
</>
)}
</Button>
<Button type="button" onClick={form.handleSubmit(onSubmit)} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
</>
)}
</Button>
</CardFooter>
</div>
</Form>
</CardContent>
</Card>
)
}

View File

@@ -41,10 +41,13 @@ export default auth((req: NextAuthRequest) => {
if (pathname.startsWith("/parent/") && role !== "parent") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
if (pathname.startsWith("/management/") && role !== "admin" && role !== "teacher") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
return NextResponse.next()
})
export const config = {
matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/settings/:path*", "/profile"],
matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/management/:path*", "/settings/:path*", "/profile"],
}

View File

@@ -4,6 +4,10 @@ import * as React from "react"
import { SessionProvider } from "next-auth/react"
export function AuthSessionProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
return (
<SessionProvider refetchOnWindowFocus={false} refetchInterval={0}>
{children}
</SessionProvider>
)
}

View File

@@ -54,7 +54,7 @@ export function OnboardingGate() {
const role = String(json.role ?? "student") as Role
setRequired(required)
setCurrentRole(role)
setRole(role === "admin" ? "admin" : "student")
setRole(role === "admin" ? "admin" : role)
setName(String(session?.user?.name ?? "").trim())
if (required) {
setOpen(true)

View File

@@ -597,6 +597,23 @@ export const homeworkAnswers = mysqlTable("homework_answers", {
}),
}));
export const aiProviders = mysqlTable("ai_providers", {
id: id("id").primaryKey(),
provider: mysqlEnum("provider", ["zhipu", "openai", "gemini", "custom"]).notNull(),
baseUrl: varchar("base_url", { length: 512 }),
model: varchar("model", { length: 128 }).notNull(),
apiKeyEncrypted: text("api_key_encrypted").notNull(),
apiKeyLast4: varchar("api_key_last4", { length: 4 }),
isDefault: boolean("is_default").default(false).notNull(),
createdBy: varchar("created_by", { length: 128 }),
updatedBy: varchar("updated_by", { length: 128 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
providerIdx: index("ai_provider_idx").on(table.provider),
defaultIdx: index("ai_provider_default_idx").on(table.isDefault),
}));
// Re-export old courses table if needed or deprecate it.
// Assuming we are replacing the old simple schema with this robust one.
// But if there were existing tables, we might keep them or comment them out.

247
src/shared/lib/ai.ts Normal file
View File

@@ -0,0 +1,247 @@
import "server-only"
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"
import OpenAI from "openai"
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"
import { env } from "@/env.mjs"
import { db } from "@/shared/db"
import { aiProviders } from "@/shared/db/schema"
import { desc, eq } from "drizzle-orm"
type ChatRole = "system" | "user" | "assistant"
type ChatMessage = {
role: ChatRole
content: string
}
type AiChatRequest = {
messages: ChatCompletionMessageParam[]
model: string
temperature: number
maxTokens?: number
thinking?: Record<string, unknown>
providerId?: string
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const isChatMessage = (v: unknown): v is ChatMessage => {
if (!isRecord(v)) return false
const role = String(v.role ?? "")
if (role !== "system" && role !== "user" && role !== "assistant") return false
const content = String(v.content ?? "")
return content.trim().length > 0
}
const extractText = (value: unknown): string => {
if (typeof value === "string") return value.trim()
if (Array.isArray(value)) {
const joined = value.map((item) => extractText(item)).filter(Boolean).join("\n")
return joined.trim()
}
if (isRecord(value)) {
const candidates = ["text", "content", "output_text", "reasoning", "reasoning_content", "thinking"]
for (const key of candidates) {
const text = extractText(value[key])
if (text) return text
}
}
return ""
}
const extractMessageContent = (message: unknown): string => {
if (!isRecord(message)) return ""
const direct = extractText(message.content)
if (direct) return direct
const candidates = ["reasoning", "reasoning_content", "thinking", "output", "text"]
for (const key of candidates) {
const text = extractText(message[key])
if (text) return text
}
for (const value of Object.values(message)) {
const text = extractText(value)
if (text) return text
}
return ""
}
export const parseAiChatPayload = (body: unknown): AiChatRequest => {
if (!isRecord(body)) throw new Error("Invalid payload")
const rawMessages = Array.isArray(body.messages) ? body.messages : []
const messages = rawMessages
.filter(isChatMessage)
.map((m) => ({ role: m.role, content: m.content })) as ChatCompletionMessageParam[]
if (messages.length === 0) throw new Error("Messages are required")
const model = String(body.model ?? env.AI_MODEL ?? "gpt-4o-mini").trim()
const temperatureRaw = Number(body.temperature ?? 0.2)
const temperature = Number.isFinite(temperatureRaw) ? Math.min(Math.max(temperatureRaw, 0), 2) : 0.2
const maxTokensRaw = Number(body.max_tokens ?? body.maxTokens ?? 0)
const maxTokens = Number.isFinite(maxTokensRaw) && maxTokensRaw > 0 ? Math.floor(maxTokensRaw) : undefined
const thinking = isRecord(body.thinking) ? body.thinking : undefined
const providerId = typeof body.providerId === "string" ? body.providerId.trim() : undefined
return {
messages,
model,
temperature,
maxTokens,
thinking,
providerId: providerId && providerId.length > 0 ? providerId : undefined,
}
}
const getEncryptionKey = () => {
const secret = String(env.NEXTAUTH_SECRET ?? "").trim()
if (!secret) throw new Error("AI encryption secret missing")
return createHash("sha256").update(secret).digest()
}
export const encryptAiApiKey = (value: string) => {
const iv = randomBytes(12)
const key = getEncryptionKey()
const cipher = createCipheriv("aes-256-gcm", key, iv)
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString("base64")
}
export const decryptAiApiKey = (value: string) => {
const raw = Buffer.from(value, "base64")
if (raw.length < 28) throw new Error("Invalid API key payload")
const iv = raw.subarray(0, 12)
const tag = raw.subarray(12, 28)
const encrypted = raw.subarray(28)
const key = getEncryptionKey()
const decipher = createDecipheriv("aes-256-gcm", key, iv)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
return decrypted.toString("utf8")
}
const getAiProviderConfig = async (providerId?: string) => {
if (providerId) {
const [selected] = await db
.select({
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
})
.from(aiProviders)
.where(eq(aiProviders.id, providerId))
.limit(1)
if (!selected) throw new Error("AI provider not configured")
return {
apiKey: decryptAiApiKey(selected.apiKeyEncrypted),
baseUrl: selected.baseUrl ?? undefined,
model: selected.model,
}
}
const [active] = await db
.select({
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
})
.from(aiProviders)
.where(eq(aiProviders.isDefault, true))
.orderBy(desc(aiProviders.updatedAt))
.limit(1)
if (active) {
return {
apiKey: decryptAiApiKey(active.apiKeyEncrypted),
baseUrl: active.baseUrl ?? undefined,
model: active.model,
}
}
const [fallback] = await db
.select({
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
})
.from(aiProviders)
.orderBy(desc(aiProviders.updatedAt))
.limit(1)
if (!fallback) throw new Error("AI provider not configured")
return {
apiKey: decryptAiApiKey(fallback.apiKeyEncrypted),
baseUrl: fallback.baseUrl ?? undefined,
model: fallback.model,
}
}
const getAiClient = async (config: { apiKey: string; baseUrl?: string }) => {
const baseUrl = String(config.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "")
return new OpenAI({
apiKey: config.apiKey,
baseURL: baseUrl.length ? baseUrl : undefined,
})
}
export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: string; model: string }) => {
const client = await getAiClient({ apiKey: input.apiKey, baseUrl: input.baseUrl })
const result = await client.chat.completions.create({
model: input.model,
messages: [{ role: "user", content: "ping" }],
temperature: 0,
max_tokens: 1,
} as Parameters<typeof client.chat.completions.create>[0])
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0
if (!hasChoices) throw new Error("Empty response from provider. Check API URL, model, and API key.")
return true
}
export const testAiProviderById = async (
providerId: string,
overrides?: { baseUrl?: string; model?: string }
) => {
const config = await getAiProviderConfig(providerId)
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
const result = await client.chat.completions.create({
model: overrides?.model ?? config.model,
messages: [{ role: "user", content: "ping" }],
temperature: 0,
max_tokens: 1,
} as Parameters<typeof client.chat.completions.create>[0])
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0
if (!hasChoices) throw new Error("Empty response from provider. Check API URL, model, and API key.")
return true
}
export const createAiChatCompletion = async (input: AiChatRequest) => {
const config = await getAiProviderConfig(input.providerId)
const client = await getAiClient(config)
const result = (await client.chat.completions.create({
model: config.model || input.model,
messages: input.messages,
temperature: input.temperature,
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
...(input.thinking ? { thinking: input.thinking } : {}),
} as Parameters<typeof client.chat.completions.create>[0])) as Awaited<
ReturnType<typeof client.chat.completions.create>
>
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0
if (!hasChoices) throw new Error("Empty response from provider. Check API URL, model, and API key.")
const content = extractMessageContent(result.choices?.[0]?.message)
if (!content.trim()) throw new Error("Empty response content. Check model output settings.")
const usage = "usage" in result ? result.usage ?? null : null
return { content, usage }
}
export const getAiErrorMessage = (v: unknown) => {
if (v instanceof Error) return v.message
if (!isRecord(v)) return "AI request failed"
const message = String(v.message ?? "")
return message.trim().length ? message : "AI request failed"
}