diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index c57acfc..be9b7a4 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -7,6 +7,9 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline' import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh' import SaveIcon from '@mui/icons-material/Save' import MouseIcon from '@mui/icons-material/Mouse' +import CheckIcon from '@mui/icons-material/Check' +import CloseIcon from '@mui/icons-material/Close' +import DoneAllIcon from '@mui/icons-material/DoneAll' import '@tldraw/tldraw/tldraw.css' import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw' import axios from 'axios' @@ -82,6 +85,21 @@ function reviewSummary(template: ExamTemplateDetail | null) { }, { ai: 0, unconfirmed: 0, lowConfidence: 0 }) } +// Live review counts read straight off the canvas shapes, so accept/reject update them immediately +// (the template-derived reviewSummary only reflects the last *saved* state). +function reviewFromShapes(editor: Editor | null) { + const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 } + if (!editor) return acc + for (const shape of editor.getCurrentPageShapes()) { + if (!shapeTypeToKind(shape.type)) continue + const p = (shape as ExamCanvasTLShape).props + if (p.source === 'ai') acc.ai += 1 + if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1 + if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1 + } + return acc +} + function stripShapePrefix(id: string) { return id.startsWith('shape:') ? id.slice('shape:'.length) : id } @@ -205,8 +223,11 @@ const ExamTemplateSetupInner: React.FC = () => { const [guideOpen, setGuideOpen] = useState(false) const [autoMapStatus, setAutoMapStatus] = useState(null) const [autoMapBusy, setAutoMapBusy] = useState(false) + const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 }) const autoMapPollRef = useRef(null) + const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), []) + const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => { setTemplate(detail) const editor = editorRef.current @@ -219,7 +240,33 @@ const ExamTemplateSetupInner: React.FC = () => { setDirty(false) }, []) - const review = useMemo(() => reviewSummary(template), [template]) + const acceptShapes = useCallback((shapes: TLShape[]) => { + const editor = editorRef.current + if (!editor) return + const updates = shapes + .filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false) + .map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } })) + if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) } + }, [refreshReview]) + + const selectedDomainShapes = useCallback((): TLShape[] => { + const editor = editorRef.current + if (!editor) return [] + return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type)) + }, []) + + const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes]) + const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes]) + const rejectSelected = useCallback(() => { + const editor = editorRef.current + if (!editor) return + const ids = selectedDomainShapes().map((s) => s.id) + if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) } + }, [refreshReview, selectedDomainShapes]) + + // Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the + // saved-template summary only for the brief moment before shapes have mounted). + const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template) const load = useCallback(async () => { if (!templateId) return @@ -416,7 +463,23 @@ const ExamTemplateSetupInner: React.FC = () => { onMount={(editor) => { editorRef.current = editor editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) - editor.store.listen(() => setDirty(true), { scope: 'document' }) + editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' }) + // Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who + // adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only + // update below can't recurse. + editor.store.listen((entry) => { + try { + const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = [] + for (const rec of Object.values(entry.changes.updated)) { + const [from, to] = rec as [any, any] + if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue + if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue + const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h + if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } }) + } + if (updates.length) editor.updateShapes(updates as any) + } catch { /* non-fatal: implicit-confirm is best-effort */ } + }, { scope: 'document', source: 'user' }) applyDocViewConstraints(editor, []) editor.resetZoom() // Only seed the example guide for a genuinely-empty template AFTER it has loaded. @@ -432,6 +495,22 @@ const ExamTemplateSetupInner: React.FC = () => { /> + {/* AI review actions — appears while there are unconfirmed suggestions to resolve */} + {review.unconfirmed > 0 && ( + + + + + + + + + + + + + )} + {/* Guide toggle */} setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}> diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index b388475..65935c5 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -81,6 +81,12 @@ function confidenceLabel(confidence: number | null | undefined) { return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null } +// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid. +function ghostOpacity(confidence: number | null | undefined) { + if (typeof confidence !== 'number') return 0.72 + return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence)) +} + function reviewFlags(shape: ExamCanvasTLShape): string[] { return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean) } @@ -100,16 +106,17 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) { const p = canvasShapePalette.boundary const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2)) const isAi = shape.props.source === 'ai' + const isGhost = isAi && shape.props.confirmed === false const confidence = confidenceLabel(shape.props.confidence) const flags = reviewFlags(shape) return ( - + - + {shape.props.label || p.label} {confidence && {confidence}} @@ -139,8 +146,8 @@ function renderShape(shape: ExamCanvasTLShape) { '--exam-dark-stroke': p.darkStroke, '--exam-dark-fill': p.darkFill, width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`, - borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10, - background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif', + borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10, + background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif', display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between', padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6, } as React.CSSProperties} @@ -148,7 +155,7 @@ function renderShape(shape: ExamCanvasTLShape) { title={title} > - + {shape.props.label || p.label} {confidence && {confidence}}