Public Access
feat: integrate AI features for question generation and tutoring
- Added AI routes for generating questions, providing hints, and tutor chat functionality. - Implemented AI client setup and model retrieval in aiClient.ts. - Created aiAssist.ts for handling AI-related operations including question generation and hint retrieval. - Updated services to utilize the new AI client and assist functions. - Enhanced frontend components to support AI question generation and tutor chat, including UI for generating questions and displaying hints. - Added localization strings for new AI features in English and Dutch. - Updated exam review process to include AI-generated hints and tutor chat functionality.
This commit is contained in:
1 parent
1ec1633dd9
commit
63aa9b572a
16 files changed
+881
-36
No files matched your search
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as examApi from '../api/examApi'
|
||||
import * as aiApi from '../api/aiApi'
|
||||
import type { Exam, Question, QuestionType, Choice } from '../types'
|
||||
|
||||
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
||||
@@ -59,6 +60,7 @@ export default function ExamEditor() {
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(isNew)
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||
|
||||
// Load existing exam
|
||||
useEffect(() => {
|
||||
@@ -92,6 +94,22 @@ export default function ExamEditor() {
|
||||
setSelectedIdx(exam.questions.length)
|
||||
}
|
||||
|
||||
const appendGeneratedQuestions = useCallback((generated: aiApi.GeneratedQuestion[]) => {
|
||||
let firstNewIdx = -1
|
||||
updateExam((draft) => {
|
||||
firstNewIdx = draft.questions.length
|
||||
for (const g of generated) {
|
||||
const idx = draft.questions.length
|
||||
const merged: Question = {
|
||||
...(g as Omit<Question, 'id'>),
|
||||
id: `q${idx + 1}`,
|
||||
}
|
||||
draft.questions.push(merged)
|
||||
}
|
||||
})
|
||||
if (firstNewIdx >= 0) setSelectedIdx(firstNewIdx)
|
||||
}, [updateExam])
|
||||
|
||||
const removeQuestion = (idx: number) => {
|
||||
updateExam((draft) => {
|
||||
draft.questions.splice(idx, 1)
|
||||
@@ -202,6 +220,21 @@ export default function ExamEditor() {
|
||||
|
||||
<div className="exam-editor-add">
|
||||
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
onClick={() => setAiPanelOpen((v) => !v)}
|
||||
>
|
||||
{aiPanelOpen ? t('editor.aiClose') : t('editor.aiGenerate')}
|
||||
</button>
|
||||
{aiPanelOpen && (
|
||||
<AIGeneratePanel
|
||||
onGenerated={(qs) => {
|
||||
appendGeneratedQuestions(qs)
|
||||
setAiPanelOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="exam-editor-type-grid">
|
||||
{QUESTION_TYPES.map((tType) => (
|
||||
<button
|
||||
@@ -309,6 +342,108 @@ function TagsEditor({ tags, onChange }: { tags: string[]; onChange: (tags: strin
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── AI Generate Panel ─── */
|
||||
|
||||
const AI_GENERATE_TYPES: { value: QuestionType; labelKey: string }[] = QUESTION_TYPES
|
||||
|
||||
function AIGeneratePanel({ onGenerated }: { onGenerated: (qs: aiApi.GeneratedQuestion[]) => void }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [topic, setTopic] = useState('')
|
||||
const [type, setType] = useState<QuestionType>('short-answer')
|
||||
const [count, setCount] = useState(3)
|
||||
const [context, setContext] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const submit = async () => {
|
||||
if (!topic.trim()) {
|
||||
setErr(t('editor.aiTopicRequired'))
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const resp = await aiApi.generateQuestions({
|
||||
topic: topic.trim(),
|
||||
type,
|
||||
count,
|
||||
language: i18n.language || 'en',
|
||||
context: context.trim() || undefined,
|
||||
})
|
||||
onGenerated(resp.questions)
|
||||
setTopic('')
|
||||
setContext('')
|
||||
} catch (e) {
|
||||
setErr((e as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
border: '1px solid var(--line-2)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--paper)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{t('editor.aiPanelTitle')}</div>
|
||||
<input
|
||||
className="input"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder={t('editor.aiTopicPlaceholder')}
|
||||
disabled={busy}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={t('editor.aiContextPlaceholder')}
|
||||
disabled={busy}
|
||||
rows={3}
|
||||
style={{ fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<select
|
||||
className="input"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as QuestionType)}
|
||||
disabled={busy}
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
>
|
||||
{AI_GENERATE_TYPES.map((tt) => (
|
||||
<option key={tt.value} value={tt.value}>
|
||||
{t(tt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Math.max(1, Math.min(10, Number(e.target.value) || 1)))}
|
||||
disabled={busy}
|
||||
style={{ width: 60, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
{err && <div style={{ fontSize: 11, color: 'var(--wrong)' }}>{err}</div>}
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? t('editor.aiGenerating') : t('editor.aiGenerateNow')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Question Editor ─── */
|
||||
|
||||
interface QEProps {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { Exam, ExamState, Question } from "../types";
|
||||
import { gradeQuestion } from "../utils/grading";
|
||||
import { formatTime } from "../utils/formatting";
|
||||
import * as aiApi from "../api/aiApi";
|
||||
import Tex from "./Tex";
|
||||
import QuestionMedia from "./QuestionMedia";
|
||||
import QuestionRenderer from "./questions/QuestionRenderer";
|
||||
@@ -27,7 +28,7 @@ export default function ExamRunner({
|
||||
onNavigate,
|
||||
n,
|
||||
}: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
|
||||
const { answers, flags, startedAt, feedbackMode } = state;
|
||||
const currentIdx = urlIdx;
|
||||
@@ -40,6 +41,35 @@ export default function ExamRunner({
|
||||
setRevealed(!!state.revealed?.[q.id]);
|
||||
}, [currentIdx, q.id, state.revealed]);
|
||||
|
||||
const [hintCache, setHintCache] = useState<Record<string, string>>({});
|
||||
const [hintLoading, setHintLoading] = useState(false);
|
||||
const [hintError, setHintError] = useState<string | null>(null);
|
||||
const currentHint = hintCache[q.id];
|
||||
|
||||
useEffect(() => {
|
||||
setHintError(null);
|
||||
}, [q.id]);
|
||||
|
||||
const requestHint = async () => {
|
||||
if (currentHint || hintLoading) return;
|
||||
setHintLoading(true);
|
||||
setHintError(null);
|
||||
try {
|
||||
const safeQ: Record<string, unknown> = { ...(q as unknown as Record<string, unknown>) };
|
||||
delete safeQ.answer;
|
||||
delete safeQ.acceptedForms;
|
||||
const res = await aiApi.getHint({
|
||||
question: safeQ,
|
||||
language: i18n.language || 'en',
|
||||
});
|
||||
setHintCache((prev) => ({ ...prev, [q.id]: res.hint }));
|
||||
} catch (e) {
|
||||
setHintError((e as Error).message);
|
||||
} finally {
|
||||
setHintLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setAnswer = (val: unknown) =>
|
||||
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
|
||||
|
||||
@@ -225,6 +255,26 @@ export default function ExamRunner({
|
||||
graded={graded}
|
||||
/>
|
||||
|
||||
{(currentHint || hintError) && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
borderLeft: '3px solid var(--accent)',
|
||||
background: 'var(--paper)',
|
||||
fontSize: 13,
|
||||
color: 'var(--ink-2)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 4 }}>
|
||||
{t('exam.hintLabel')}
|
||||
</div>
|
||||
{currentHint && <Tex>{currentHint}</Tex>}
|
||||
{hintError && <span style={{ color: 'var(--wrong)' }}>{hintError}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graded && result && q.type !== "essay" && (
|
||||
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
|
||||
<div className="feedback-label">
|
||||
@@ -259,6 +309,20 @@ export default function ExamRunner({
|
||||
<button className="btn btn-ghost" onClick={toggleFlag}>
|
||||
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
|
||||
</button>
|
||||
{!revealed && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void requestHint()}
|
||||
disabled={hintLoading || !!currentHint}
|
||||
title={t('exam.hintTitle')}
|
||||
>
|
||||
{hintLoading
|
||||
? t('exam.hintLoading')
|
||||
: currentHint
|
||||
? t('exam.hintShown')
|
||||
: t('exam.needHint')}
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
{feedbackMode === "immediate" &&
|
||||
!revealed &&
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function MyResults({ onLoadExam }: Props) {
|
||||
...(state || {}),
|
||||
completedAt: typeof completedAt === 'number' ? completedAt * 1000 : Date.now(),
|
||||
}))
|
||||
localStorage.setItem('brainwave.review.resultId', String(item.id))
|
||||
navigate(`/review/${getExamUrlId(data.exam as unknown as { title: string; id?: string })}`)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function Overview({ examCount, resultCount, recentResults, onLoad
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
{/* <div
|
||||
className="workspace-card"
|
||||
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}
|
||||
>
|
||||
@@ -189,7 +189,7 @@ export default function Overview({ examCount, resultCount, recentResults, onLoad
|
||||
{importing ? t('workspace.importing') : t('workspace.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<button
|
||||
className="workspace-card"
|
||||
|
||||
@@ -7,6 +7,7 @@ import Tex from './Tex'
|
||||
import QuestionMedia from './QuestionMedia'
|
||||
import QuestionRenderer from './questions/QuestionRenderer'
|
||||
import * as gradeApi from '../api/gradeApi'
|
||||
import * as aiApi from '../api/aiApi'
|
||||
|
||||
interface Props {
|
||||
exam: Exam
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
onExit: () => void
|
||||
onRedoFailed?: () => void
|
||||
reviewOnly?: boolean
|
||||
resultId?: number | null
|
||||
}
|
||||
|
||||
const AI_TYPES = new Set(['essay', 'short-answer', 'translation', 'math-input', 'match', 'order'])
|
||||
@@ -35,7 +37,7 @@ function mapTypeForApi(q: Question): gradeApi.GradeRequest['type'] | null {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly }: Props) {
|
||||
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly, resultId }: Props) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { answers, startedAt, completedAt } = state
|
||||
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
||||
@@ -167,6 +169,8 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultId != null && <TutorChatPanel resultId={resultId} />}
|
||||
|
||||
<h2 className="review-heading">{t('results.review')}</h2>
|
||||
{exam.questions.map((q, i) => {
|
||||
const g = graded[i]
|
||||
@@ -364,3 +368,121 @@ function AiDetail({ detail, type }: { detail: unknown; type: string }) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function TutorChatPanel({ resultId }: { resultId: number }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<aiApi.TutorChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
const next: aiApi.TutorChatMessage[] = [...messages, { role: 'user', content: text }]
|
||||
setMessages(next)
|
||||
setInput('')
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const res = await aiApi.tutorChat({
|
||||
resultId,
|
||||
messages: next,
|
||||
language: i18n.language || 'en',
|
||||
})
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: res.reply }])
|
||||
} catch (e) {
|
||||
setErr((e as Error).message)
|
||||
setMessages((prev) => prev.slice(0, -1))
|
||||
setInput(text)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: '24px 0', border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)' }}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '14px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--ink-1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span>{t('results.tutor.title')}</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>{open ? '−' : '+'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ padding: '0 16px 16px' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 10 }}>
|
||||
{t('results.tutor.subtitle')}
|
||||
</div>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-4)', marginBottom: 10, fontStyle: 'italic' }}>
|
||||
{t('results.tutor.placeholder')}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
background: m.role === 'user' ? 'var(--accent-weak, var(--paper))' : 'var(--paper)',
|
||||
border: '1px solid var(--line-1)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ marginBottom: 8, padding: '6px 10px', borderRadius: 6, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 12 }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void send()
|
||||
}
|
||||
}}
|
||||
placeholder={t('results.tutor.inputPlaceholder')}
|
||||
disabled={busy}
|
||||
style={{ flex: 1, fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void send()}
|
||||
disabled={busy || !input.trim()}
|
||||
>
|
||||
{busy ? t('common.loading') : t('results.tutor.send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user