=test_update_homework_tests_and_work_log
Some checks failed
CI / build-deploy (push) Has been cancelled
Some checks failed
CI / build-deploy (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
28
src/app/api/ai/chat/route.ts
Normal file
28
src/app/api/ai/chat/route.ts
Normal 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) })
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
912
src/modules/exams/ai-pipeline.ts
Normal file
912
src/modules/exams/ai-pipeline.ts
Normal 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
@@ -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,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
204
src/modules/settings/actions.ts
Normal file
204
src/modules/settings/actions.ts
Normal 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) }
|
||||
}
|
||||
}
|
||||
405
src/modules/settings/components/ai-provider-settings-card.tsx
Normal file
405
src/modules/settings/components/ai-provider-settings-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
247
src/shared/lib/ai.ts
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user