= {
'Content-Type': 'application/json',
'x-model': modelConfig.modelString,
'x-api-key': modelConfig.apiKey,
};
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
const res = await fetch('/api/quiz-grade', {
method: 'POST',
headers,
body: JSON.stringify({
question: q.question,
userAnswer,
points: pts,
commentPrompt: q.commentPrompt,
language,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { score: number; comment: string };
const earned = Math.max(0, Math.min(pts, data.score));
return {
questionId: q.id,
correct: earned >= pts * 0.8,
status: earned >= pts * 0.8 ? 'correct' : 'incorrect',
earned,
aiComment: data.comment,
};
} catch (err) {
log.error('[quiz-view] AI grading failed for', q.id, err);
// Fallback: give half credit
return {
questionId: q.id,
correct: null,
status: 'incorrect',
earned: Math.round(pts * 0.5),
aiComment:
language === 'zh-CN'
? '评分服务暂时不可用,已给予基础分。'
: 'Grading service unavailable. Base score given.',
};
}
}
// ─── Sub-components ─────────────────────────────────────────────────────────
function QuizCover({
questionCount,
totalPoints,
onStart,
}: {
questionCount: number;
totalPoints: number;
onStart: () => void;
}) {
const { t } = useI18n();
return (
{/* Background decoration */}
{t('quiz.title')}
{t('quiz.subtitle')}
{questionCount} {t('quiz.questionsCount')}
{t('quiz.totalPrefix')} {totalPoints} {t('quiz.pointsSuffix')}
{t('quiz.startQuiz')}
);
}
function SingleChoiceQuestion({
question,
index,
value,
onChange,
disabled,
result,
}: {
question: QuizQuestion;
index: number;
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
result?: QuestionResult;
}) {
const isReview = !!result;
return (
{question.options?.map((opt) => {
const selected = value === opt.value;
const isCorrectOpt = isReview && question.answer?.includes(opt.value);
const isWrong = isReview && selected && result?.status === 'incorrect';
return (
);
})}
);
}
function MultipleChoiceQuestion({
question,
index,
value,
onChange,
disabled,
result,
}: {
question: QuizQuestion;
index: number;
value?: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
result?: QuestionResult;
}) {
const isReview = !!result;
const selected = value ?? [];
const toggle = (optValue: string) => {
if (disabled) return;
if (selected.includes(optValue)) {
onChange(selected.filter((v) => v !== optValue));
} else {
onChange([...selected, optValue]);
}
};
const { t } = useI18n();
return (
{!isReview && (
{t('quiz.multipleChoiceHint')}
)}
{question.options?.map((opt) => {
const isSelected = selected.includes(opt.value);
const isCorrectOpt = isReview && question.answer?.includes(opt.value);
const isWrong = isReview && isSelected && !isCorrectOpt;
return (
);
})}
);
}
function ShortAnswerQuestion({
question,
index,
value,
onChange,
disabled,
result,
}: {
question: QuizQuestion;
index: number;
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
result?: QuestionResult;
}) {
const isReview = !!result;
const { t } = useI18n();
// Ref to track latest value for voice transcription append
const valueRef = useRef(value);
useEffect(() => {
valueRef.current = value;
}, [value]);
return (
{!isReview ? (
) : (
{t('quiz.yourAnswer')}
{value ? (
) : (
{t('quiz.notAnswered')}
)}
{result.aiComment && (
{result.earned}/{question.points ?? 1}
{t('quiz.pointsSuffix')}
)}
)}
);
}
function QuestionCard({
question,
index,
result,
children,
}: {
question: QuizQuestion;
index: number;
result?: QuestionResult;
children: React.ReactNode;
}) {
const { t } = useI18n();
const isReview = !!result;
const pts = question.points ?? 1;
return (
{/* Left accent */}
{/* Header */}
{index + 1}
{question.type === 'single'
? t('quiz.singleChoice')
: question.type === 'multiple'
? t('quiz.multipleChoice')
: t('quiz.shortAnswer')}
{' · '}
{pts} {t('quiz.pointsSuffix')}
{isReview && (
{result.status === 'correct' && }
{result.status === 'incorrect' && }
)}
{/* Body */}
{children}
{/* Analysis (review only) */}
{isReview && question.analysis && (
{t('quiz.analysis')}
)}
);
}
function ScoreBanner({
score,
total,
results,
}: {
score: number;
total: number;
results: QuestionResult[];
}) {
const { t } = useI18n();
const pct = total > 0 ? Math.round((score / total) * 100) : 0;
const correctCount = results.filter((r) => r.status === 'correct').length;
const incorrectCount = results.filter((r) => r.status === 'incorrect').length;
const color = pct >= 80 ? 'emerald' : pct >= 60 ? 'amber' : 'red';
const colorMap = {
emerald: {
bg: 'from-emerald-500 to-teal-500',
shadow: 'shadow-emerald-200/50 dark:shadow-emerald-900/50',
ring: 'bg-emerald-400/30',
text: t('quiz.excellent'),
},
amber: {
bg: 'from-amber-500 to-yellow-500',
shadow: 'shadow-amber-200/50 dark:shadow-amber-900/50',
ring: 'bg-amber-400/30',
text: t('quiz.keepGoing'),
},
red: {
bg: 'from-red-500 to-rose-500',
shadow: 'shadow-red-200/50 dark:shadow-red-900/50',
ring: 'bg-red-400/30',
text: t('quiz.needsReview'),
},
};
const c = colorMap[color];
return (
{c.text}
{score}
/ {total}
{correctCount} {t('quiz.correct')}
{incorrectCount} {t('quiz.incorrect')}
{/* Percentage ring */}
);
}
// ─── Main Component ─────────────────────────────────────────────────────────
export function QuizView({ questions, sceneId, stageId }: QuizViewProps) {
const { t, locale } = useI18n();
const [phase, setPhase] = useState('not_started');
const [answers, setAnswers] = useState>({});
const [results, setResults] = useState([]);
const [runtimeGate, setRuntimeGate] = useState({ status: 'loading' });
const [hydrationVersion, setHydrationVersion] = useState(0);
const [retrying, setRetrying] = useState(false);
const viewLifetimeRef = useRef(null);
viewLifetimeRef.current ??= createQuizViewLifetime();
const viewLifetime = viewLifetimeRef.current;
const runtimeWriterRef = useRef(null);
runtimeWriterRef.current ??= createQuizAttemptWriter({
onError: (error) => log.warn('Failed to persist quiz runtime:', error),
});
const runtimeWriter = runtimeWriterRef.current;
useEffect(() => {
return () => {
void runtimeWriter.flushDraft();
};
}, [runtimeWriter]);
useEffect(() => {
let cancelled = false;
setRuntimeGate({ status: 'loading' });
setRetrying(false);
void loadQuizAttemptState({ stageId, sceneId })
.then(({ attemptId: nextAttemptId, state }) => {
if (cancelled) return;
const next = quizViewStateFromAttempt(state);
setPhase(next.phase);
setAnswers(next.answers);
setResults(next.results);
setRuntimeGate({ status: 'ready', attemptId: nextAttemptId });
})
.catch((error) => {
log.warn('Failed to hydrate quiz runtime:', error);
if (!cancelled) setRuntimeGate({ status: 'error' });
});
return () => {
cancelled = true;
viewLifetime.invalidate();
void runtimeWriter.flushDraft();
};
}, [hydrationVersion, runtimeWriter, sceneId, stageId, viewLifetime]);
const attemptId = isQuizRuntimeReady(runtimeGate) ? runtimeGate.attemptId : null;
const totalPoints = useMemo(
() => questions.reduce((sum, q) => sum + (q.points ?? 1), 0),
[questions],
);
const allAnswered = useMemo(() => {
return questions.every((q) => {
const a = answers[q.id];
if (!a) return false;
if (Array.isArray(a)) return a.length > 0;
return (a as string).trim().length > 0;
});
}, [questions, answers]);
const handleSetAnswer = useCallback(
(questionId: string, value: string | string[]) => {
setAnswers((prev) => {
const next = { ...prev, [questionId]: value };
if (attemptId) {
writeDraftRecovery(sceneId, attemptId, next);
runtimeWriter.scheduleDraft({
stageId,
sceneId,
attemptId,
answers: next,
});
}
return next;
});
},
[attemptId, runtimeWriter, sceneId, stageId],
);
const handleSubmit = useCallback(async () => {
if (!attemptId) return;
setPhase('submitting');
await runQuizPersistenceTransition(
() => persistQuizSubmission({ stageId, sceneId, attemptId, answers }, runtimeWriter),
viewLifetime,
() => setPhase('grading'),
(error) => {
log.warn('Failed to persist quiz submission:', error);
setRuntimeGate({ status: 'error' });
},
);
}, [attemptId, answers, runtimeWriter, sceneId, stageId, viewLifetime]);
// When entering grading phase, grade choice questions locally + call API for short-answer
useEffect(() => {
if (phase !== 'grading') return;
let cancelled = false;
(async () => {
// 1. Grade choice questions locally (instant)
const choiceResults = gradeChoiceQuestions(questions, answers);
// 2. Grade short-answer questions via AI API (parallel)
const shortAnswerQs = questions.filter(isShortAnswer);
const aiResults = await Promise.all(
shortAnswerQs.map((q) =>
gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', locale),
),
);
if (cancelled) return;
// 3. Merge results in original question order
const allResultsMap = new Map();
for (const r of [...choiceResults, ...aiResults]) {
allResultsMap.set(r.questionId, r);
}
const ordered = questions.map((q) => allResultsMap.get(q.id)!).filter(Boolean);
if (!attemptId) {
setRuntimeGate({ status: 'error' });
return;
}
try {
await persistQuizReview(
{ stageId, sceneId, attemptId, answers, results: ordered },
runtimeWriter,
);
} catch (error) {
log.warn('Failed to persist quiz review:', error);
if (!cancelled) setRuntimeGate({ status: 'error' });
return;
}
if (cancelled) return;
setResults(ordered);
setPhase('reviewing');
})();
return () => {
cancelled = true;
};
}, [phase, questions, answers, locale, sceneId, stageId, attemptId, runtimeWriter]);
const handleRetry = useCallback(async () => {
if (!attemptId || retrying) return;
setRetrying(true);
runtimeWriter.cancelDraft();
await runQuizPersistenceTransition(
() => persistQuizRetry({ stageId, sceneId, attemptId }, runtimeWriter),
viewLifetime,
() => {
setPhase('not_started');
setAnswers({});
setResults([]);
setRetrying(false);
},
(error) => {
log.warn('Failed to persist quiz retry:', error);
setRetrying(false);
if (error instanceof QuizRetryProgressedError) {
setHydrationVersion((version) => version + 1);
return;
}
setRuntimeGate({ status: 'error' });
},
);
}, [attemptId, retrying, runtimeWriter, sceneId, stageId, viewLifetime]);
const earnedScore = useMemo(() => results.reduce((sum, r) => sum + r.earned, 0), [results]);
const resultMap = useMemo(() => {
const map: Record = {};
results.forEach((r) => {
map[r.questionId] = r;
});
return map;
}, [results]);
if (runtimeGate.status === 'error') {
return (
);
}
if (!isQuizRuntimeReady(runtimeGate)) {
return (
);
}
return (
{phase === 'not_started' && (
setPhase('answering')}
/>
)}
{phase === 'answering' && (
{/* Header bar */}
{t('quiz.answering')}
{
Object.keys(answers).filter((k) => {
const a = answers[k];
if (Array.isArray(a)) return a.length > 0;
return typeof a === 'string' && a.trim().length > 0;
}).length
}{' '}
/ {questions.length}
{/* Questions */}
{questions.map((q, i) => {
if (q.type === 'single') {
return (
handleSetAnswer(q.id, v)}
/>
);
}
if (q.type === 'multiple') {
return (
handleSetAnswer(q.id, v)}
/>
);
}
return (
handleSetAnswer(q.id, v)}
/>
);
})}
)}
{(phase === 'submitting' || phase === 'grading') && (
{t('quiz.aiGrading')}
{t('quiz.aiGradingWait')}
{[0, 1, 2].map((i) => (
))}
)}
{phase === 'reviewing' && (
{/* Header bar */}
{t('quiz.quizReport')}
{/* Results */}
{questions.map((q, i) => {
const r = resultMap[q.id];
if (q.type === 'single') {
return (
{}}
disabled
result={r}
/>
);
}
if (q.type === 'multiple') {
return (
{}}
disabled
result={r}
/>
);
}
return (
{}}
disabled
result={r}
/>
);
})}
)}
);
}