'use client'; /** * PBL v2 — Submission side panel. * * Right column of the Workspace. Shows the current microtask's * description + hints, plus the submission flow (paste text or * upload a small file). Existing submissions for the task are * listed so the learner can see what they have already submitted. * * Architecture note (intentional difference from the v1 Python repo): * v2 is client-truth — the project lives in the Stage store, and * the PBL routes are stateless. So submission goes through * `addSubmission` directly on a project clone, and we publish via * `onProjectChange`. NO server route. The submission rides along * on the next `/api/pbl/v2/instructor` or `/api/pbl/v2/evaluate` * POST body (the route reads from the project parameter). * * Trade-off: a learner could lose a draft if they close the tab * before publishing. We mitigate by writing to project state on * every "Submit" click — once it's in the Stage store it's * persisted to IndexedDB by the OpenMAIC stage layer. The Modal's * in-progress text remains in component state and is intentionally * ephemeral. * * File handling: we read the file via `file.text()` (small text * files only) per the 5MB cap. Binary parsing (PDF, image OCR) is * explicitly out of scope per the PR 6 D2-A decision. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CheckCircle2, Download, Eye, FileText, Image as ImageIcon, Lightbulb, Paperclip, Upload, X, } from 'lucide-react'; import { addSubmission, listSubmissionsForMicrotask, } from '@/lib/pbl/v2/operations/runtime/submission'; import { findModelById } from '@/lib/ai/model-aliases'; import { TEXT_PDF_IMAGE_ACCEPT, isImageFile, isPdfFile, isValidTextFile, } from '@/lib/pbl/v2/operations/runtime/file-validation'; import { uploadBlobToStorage } from '@/lib/storage/client'; import type { PBLChatMessage, PBLEvaluation, PBLProjectV2, PBLSubmission, } from '@/lib/pbl/v2/types'; import { trimmedPBLText } from '@/lib/pbl/v2/readers'; import type { PBLSSEEvent } from '@/lib/pbl/v2/api/sse'; import { applyInstructorEvent } from './apply-instructor-event'; import { getCurrentModelConfig } from '@/lib/utils/model-config'; import { useSettingsStore } from '@/lib/store/settings'; import { normalizeProjectRuntime } from '@/lib/pbl/v2/operations/kernel/progress'; import { appendRuntimeEvent, milestoneIdForMicrotask, mintRuntimeEventId, } from '@/lib/pbl/v2/operations/kernel/runtime-events'; import { trackSubmissionScore } from '@/lib/pbl/v2/operations/runtime/dynamic-signals'; import { appendTaskCompletionReadyMessage, recordPendingTaskCompletionEvidence, setPendingTaskCompletion, TASK_EVAL_PASS_SCORE, taskEvaluationCanComplete, } from '@/lib/pbl/v2/operations/kernel/task-completion'; import { useI18n } from '@/lib/hooks/use-i18n'; import i18n from '@/lib/i18n/config'; import { assertNotStreamError, isToleratedReactionStreamError, type StreamStatus, } from './use-instructor-stream'; interface Props { readonly project: PBLProjectV2; readonly onProjectChange: (next: PBLProjectV2) => void; /** Notified after a submission is added; the parent decides whether * to auto-trigger task evaluation (PR 6.6). */ readonly onSubmissionAdded?: (submission: PBLSubmission, microtaskId: string) => void; readonly onEvaluationStatusChange?: (status: SubmissionEvaluationStatus | null) => void; /** Reports the post-submit evaluation stream's start/end up to a parent that * outlives this panel, so the workspace knows a stream is in flight even * after the learner steps back to the Hero (keeps the "thinking…" indicator * alive across a remount and blocks a reset that would clobber the result). * `onEvaluationStatusChange` is panel-local state and is lost on unmount; * this is not. */ readonly onInstructorStreamingChange?: (active: boolean) => void; /** True while ANY SSE stream is feeding the chat (the instructor's normal * reply, a task evaluation, or a milestone/stage card) — including a stream * started elsewhere. Submitting output during that window would interleave * with the in-flight response and scramble ordering, so submission is locked * while it is true. */ readonly instructorStreaming?: boolean; } /** Submission must be locked while the chat is mid-stream (so a submit can't * interleave with the instructor's in-flight reply / evaluation) and while * this panel's own post-submit evaluation is running (so the learner can't * double-submit). Pure for unit testing. */ export function isSubmitLockedDuringStream(args: { instructorStreaming?: boolean; evaluating: boolean; }): boolean { return !!args.instructorStreaming || args.evaluating; } export interface SubmissionEvaluationStatus { readonly microtaskId: string; readonly microtaskTitle: string; readonly phase: 'evaluating' | 'followup'; readonly streamStatus?: StreamStatus; readonly draft?: string; readonly startedAt: string; } /** 5 MB cap. Larger than the v1 repo's 1 MB because v2 stores * through IndexedDB which handles bigger blobs fine, and one * good-size code file (500+ lines) can blow past 1 MB easily. * Hard cap; if you raise this, also bump the corresponding * validation copy below so the user sees the right number. */ const FILE_BYTES_CAP = 5 * 1024 * 1024; // When object storage is unconfigured (e.g. local dev) an image falls back to // an inline base64 data URL stored on the project. Cap that fallback so a big // image can't bloat the project JSON / IndexedDB. With storage configured the // image goes to OSS (a small URL) and this cap doesn't apply. const IMAGE_BASE64_CAP = 2 * 1024 * 1024; /** Read a Blob as a base64 data URL (image fallback when OSS is unconfigured). */ function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error ?? new Error('read failed')); reader.readAsDataURL(blob); }); } const FOLLOWUP_THINKING_MIN_MS = 800; function parseSSEFrame(frame: string): PBLSSEEvent | null { const lines = frame.split('\n'); let eventName = ''; let dataLine = ''; for (const line of lines) { if (line.startsWith('event: ')) eventName = line.slice(7).trim(); else if (line.startsWith('data: ')) dataLine = line.slice(6); } if (!eventName || !dataLine) return null; try { return { type: eventName, ...JSON.parse(dataLine) } as PBLSSEEvent; } catch { return null; } } function delay(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function newClientMessageId(): string { return 'msg_local_' + Date.now().toString(16) + Math.random().toString(16).slice(2, 6); } function submissionReceiptText(submission: PBLSubmission, language?: string): string { const lng = language || 'zh-CN'; const label = submission.kind === 'file' && submission.filename ? i18n.t('pbl.v2.submission.receiptFile', { lng, filename: submission.filename }) : i18n.t('pbl.v2.submission.textSubmission', { lng }); return i18n.t('pbl.v2.submission.receiptTemplate', { lng, label }); } function appendSubmissionReceiptMessage( project: PBLProjectV2, submission: PBLSubmission, ): PBLProjectV2 { normalizeProjectRuntime(project); const instructorId = project.roles.find((r) => r.type === 'instructor')?.id; const thread = project.threads.find((t) => t.agentId === instructorId); if (!thread) return project; const exists = thread.messages.some( (m) => m.roleType === 'user' && m.microtaskId === submission.microtaskId && m.ts === submission.createdAt, ); if (exists) return project; const message: PBLChatMessage = { id: newClientMessageId(), roleType: 'user', content: submissionReceiptText(submission, project.language), ts: submission.createdAt, microtaskId: submission.microtaskId, }; thread.messages.push(message); appendRuntimeEvent(project, { id: mintRuntimeEventId(), kind: 'message_created', actorType: 'user', messageId: message.id, threadId: thread.agentId, ts: message.ts, microtaskId: message.microtaskId, milestoneId: submission.milestoneId ?? milestoneIdForMicrotask(project, message.microtaskId), }); project.updatedAt = submission.createdAt; return project; } function latestTaskEvaluation( project: PBLProjectV2, microtaskId: string, ): PBLEvaluation | undefined { return project.evaluations .filter((e) => e.kind === 'task' && e.microtaskId === microtaskId) .slice() .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; } /** An image is graded multimodally only on a vision-capable model. On a * non-vision model the evaluator never sees the picture (it falls back to a * text-only prompt with a placeholder), so a pure-image submission would get * a meaningless score. In that case require a text caption the text-only * evaluator can actually grade. */ export function imageRequiresCaption(args: { hasImage: boolean; hasVision: boolean; hasCaption: boolean; }): boolean { return args.hasImage && !args.hasVision && !args.hasCaption; } export function taskEvaluationCanAdvance(evaluation: PBLEvaluation | undefined): boolean { return taskEvaluationCanComplete(evaluation); } export function buildRevisionGuidanceMessage(args: { evaluation: PBLEvaluation; instructorId?: string; microtaskId: string; language?: string; revisionAttempt?: number; }): PBLChatMessage | null { if (!args.instructorId) return null; const zh = args.language === 'zh-CN' || args.language === 'zh-TW'; const attempt = Math.max(1, args.revisionAttempt ?? 1); const zhOpening = attempt <= 1 ? '这版先别急着往下走。' : attempt === 2 ? '这次还需要再补一轮。' : '还需要继续改一下。'; const enOpening = attempt <= 1 ? "Let's pause here before moving on." : attempt === 2 ? 'This still needs one more revision pass.' : 'Please keep revising this before we move on.'; const content = zh ? [ zhOpening, '', '先参照上面的任务点评,把最影响下一步的一两处改稳。改好后在右侧重新提交,我再帮你看。', ].join('\n') : [ enOpening, '', "Use the task review above to tighten the one or two points that most affect the next step. Submit the revision on the right, and I'll review it again.", ].join('\n'); return { id: 'msg_' + Date.now().toString(16) + Math.random().toString(16).slice(2, 6), agentId: args.instructorId, roleType: 'instructor', content, ts: new Date().toISOString(), microtaskId: args.microtaskId, }; } export function PBLV2SubmissionPanel({ project, onProjectChange, onSubmissionAdded, onEvaluationStatusChange, onInstructorStreamingChange, instructorStreaming, }: Props) { const { t } = useI18n(); const current = useMemo(() => { const ms = project.milestones.find((m) => m.status === 'active'); if (!ms) return undefined; const task = ms.microtasks.find((t) => t.status === 'todo' || t.status === 'in_progress'); if (!task) return undefined; return { milestone: ms, task }; }, [project.milestones]); const existing = useMemo( () => (current ? listSubmissionsForMicrotask(project, current.task.id) : []), [project, current], ); // ACT MODEL: under a roleplay act the right panel shows the WHOLE act, not a // single beat — title = act title, brief = the beats' learnerBriefs merged // (what + why for everything this act covers, no answers), hints = all beats' // hints. The learner sees everything the act asks of them up front, but is // never auto-advanced between beats. Non-roleplay tasks keep single-task display. const display = useMemo(() => { if (!current) return undefined; const { milestone, task } = current; if (milestone.scenarioStage !== 'roleplay') { return { title: task.title, brief: task.learnerBrief || task.description || '', hints: task.hints ?? [], }; } const beats = milestone.microtasks; const brief = beats .map((b) => b.learnerBrief || b.description || '') .map(trimmedPBLText) .filter(Boolean) .join('\n\n'); const hints = beats.flatMap((b) => b.hints ?? []).filter(Boolean); return { title: milestone.title, brief, hints }; }, [current]); // Project-wide submission history (all tasks/stages), newest first, so a // learner can always see — and re-open — every output they have produced, // even after advancing to later tasks or during a stage handover. This is // display only; task evaluation still reads the latest submission via // summarizeLatestSubmissionForMicrotask on the server, untouched here. const allSubmissions = useMemo( () => project.submissions.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)), [project.submissions], ); const taskTitleById = useMemo(() => { const map = new Map(); for (const ms of project.milestones) { for (const mt of ms.microtasks) map.set(mt.id, mt.title); } return map; }, [project.milestones]); const [modalOpen, setModalOpen] = useState(false); const [evaluating, setEvaluating] = useState(false); const [viewer, setViewer] = useState(null); // Lock submitting while any stream is feeding the chat (instructor reply / // task eval / stage card) or this panel's own eval is running. const submitLocked = isSubmitLockedDuringStream({ instructorStreaming, evaluating }); // Surfaced when the post-submit evaluation stream fails (HTTP non-2xx / // no body / SSE error), so the learner isn't left with a silent "已提交" // and no evaluation, guidance or progress. const [evalError, setEvalError] = useState(null); // Download the original uploaded file. Non-text uploads (PDF) keep the // original at `fileUrl` (object storage) — download straight from there. // Text uploads store their full text in `content`, so a Blob from // content + filename + mimeType is a byte-faithful copy. const downloadSubmission = useCallback((s: PBLSubmission) => { if (typeof window === 'undefined') return; if (s.fileUrl) { const a = document.createElement('a'); a.href = s.fileUrl; a.download = s.filename || 'submission'; a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); return; } // No stored original → `content` holds TEXT (raw text for text uploads, or // a PDF's parsed text). Always download as plain text so we never emit a // corrupt binary (e.g. a `.pdf` whose bytes are actually extracted text). const textLike = !s.mimeType || s.mimeType.startsWith('text/') || s.mimeType === 'application/json' || s.mimeType === 'application/xml'; const name = textLike ? s.filename || 'submission.txt' : `${(s.filename || 'submission').replace(/\.[^.]+$/, '')}.txt`; const blob = new Blob([s.content ?? ''], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }, []); const runImmediateTaskEval = async ( snapshot: PBLProjectV2, milestoneId: string, microtaskId: string, microtaskTitle: string, ) => { setEvaluating(true); onInstructorStreamingChange?.(true); setEvalError(null); const statusStartedAt = new Date().toISOString(); const setEvaluationStatus = (patch: Partial) => { onEvaluationStatusChange?.({ microtaskId, microtaskTitle, phase: 'evaluating', streamStatus: 'eval-task', draft: '', startedAt: statusStartedAt, ...patch, }); }; setEvaluationStatus({ microtaskId, microtaskTitle, phase: 'evaluating', streamStatus: 'eval-task', draft: '', startedAt: new Date().toISOString(), }); let workingProject = structuredClone(snapshot); try { const modelConfig = getCurrentModelConfig(); const runStream = async ( endpoint: string, body: Record, streamStatus: StreamStatus, onPatch?: (patch: Extract['patch']) => void, ) => { let draft = ''; setEvaluationStatus({ phase: streamStatus === 'instructor' ? 'followup' : 'evaluating', streamStatus, draft, }); const headers: Record = { '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; try { const stored = localStorage.getItem('locale'); if (stored) headers['x-user-locale'] = stored; } catch { /* noop */ } const res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body), }); if (!res.body) throw new Error('Response has no readable body.'); if (!res.ok) { const text = await res.text(); throw new Error(`HTTP ${res.status}: ${text.slice(0, 400)}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const frames = buffer.split('\n\n'); buffer = frames.pop() ?? ''; for (const frame of frames) { const parsed = parseSSEFrame(frame); if (!parsed) continue; // #593: the chained post-evaluation instructor reaction is best-effort. // A soft EMPTY_LLM_OUTPUT there means "no wrap-up", not a failed // evaluation — the task eval already succeeded and is recorded. Skip // it; real failures and any error on the eval streams still abort. if (isToleratedReactionStreamError(streamStatus, parsed)) continue; assertNotStreamError(parsed); if (parsed.type === 'project_patch') onPatch?.(parsed.patch); workingProject = applyInstructorEvent(parsed, workingProject, (fn) => { draft = streamStatus === 'eval-task' ? '' : fn(draft); setEvaluationStatus({ phase: streamStatus === 'instructor' ? 'followup' : 'evaluating', streamStatus, draft, }); }); if (parsed.type === 'project_patch') { onProjectChange(workingProject); } } } }; await runStream( '/api/pbl/v2/evaluate', { project: workingProject, kind: 'task', milestoneId, microtaskId, }, 'eval-task', ); onProjectChange(workingProject); const evaluation = latestTaskEvaluation(workingProject, microtaskId); if (!evaluation) return; // The evaluation patch above has already been published to React. Clone // before adding the ready state so the next onProjectChange uses a fresh // object reference; otherwise React may skip rendering the completion // prompt/sidebar button until a later refresh. workingProject = structuredClone(workingProject); if (typeof evaluation.score === 'number') { trackSubmissionScore(workingProject, evaluation.score); } onEvaluationStatusChange?.({ microtaskId, microtaskTitle, phase: 'followup', startedAt: new Date().toISOString(), }); if (taskEvaluationCanAdvance(evaluation)) { const milestone = workingProject.milestones.find((m) => m.id === milestoneId); const microtask = milestone?.microtasks.find((t) => t.id === microtaskId); if (milestone && microtask) { const reason = typeof evaluation.score === 'number' ? `Latest task submission passed evaluation (${evaluation.score}/100).` : 'Latest task submission passed evaluation.'; const evidence = { path: 'submission_passed' as const, signature: `submission_passed_${microtask.id}`, label: microtask.title, note: reason, }; recordPendingTaskCompletionEvidence(workingProject, milestone, microtask, evidence); setPendingTaskCompletion(workingProject, { microtaskId, milestoneId, reason, assessment: { problems: '', resolution: 'submission evaluation passed', performance: 'submission met the task threshold', }, evidence, }); appendTaskCompletionReadyMessage(workingProject, microtaskId, { afterTs: evaluation.createdAt, }); } onProjectChange(workingProject); return; } await delay(FOLLOWUP_THINKING_MIN_MS); const guidanceProject = structuredClone(workingProject); normalizeProjectRuntime(guidanceProject); const instructorId = guidanceProject.roles.find((r) => r.type === 'instructor')?.id; const revisionAttempt = guidanceProject.evaluations.filter( (e) => e.kind === 'task' && e.microtaskId === microtaskId && typeof e.score === 'number' && e.score < TASK_EVAL_PASS_SCORE, ).length; const guidance = buildRevisionGuidanceMessage({ evaluation, instructorId, microtaskId, language: guidanceProject.language, revisionAttempt, }); if (!guidance) return; const thread = guidanceProject.threads.find((t) => t.agentId === instructorId); if (thread && !thread.messages.some((m) => m.id === guidance.id)) { thread.messages.push(guidance); appendRuntimeEvent(guidanceProject, { id: mintRuntimeEventId(), kind: 'message_created', actorType: 'agent', actorRoleId: guidance.agentId, messageId: guidance.id, threadId: thread.agentId, ts: guidance.ts, microtaskId: guidance.microtaskId, milestoneId: milestoneIdForMicrotask(guidanceProject, guidance.microtaskId), }); guidanceProject.updatedAt = guidance.ts; workingProject = guidanceProject; onProjectChange(guidanceProject); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error('[PBL v2] task evaluation failed:', msg); setEvalError(msg); } finally { setEvaluating(false); onInstructorStreamingChange?.(false); onEvaluationStatusChange?.(null); } }; return ( ); } function SubmissionHistory({ submissions, taskTitleById, onView, onDownload, }: { readonly submissions: PBLSubmission[]; readonly taskTitleById: Map; readonly onView: (s: PBLSubmission) => void; readonly onDownload: (s: PBLSubmission) => void; }) { const { t } = useI18n(); return (
{t('pbl.v2.submission.submissionHistory')}
    {submissions.map((s) => { const isFile = s.kind === 'file'; const isImageSub = s.mimeType?.startsWith('image/') ?? false; // Images and text are "viewed" (preview); other files (PDF, text // files) are downloaded. const viewable = isImageSub || !isFile; const taskTitle = taskTitleById.get(s.microtaskId); const label = isFile && s.filename ? s.filename : t('pbl.v2.submission.textSubmission'); return (
  • ); })}
); } function SubmissionViewer({ submission, onClose, onDownload, }: { readonly submission: PBLSubmission; readonly onClose: () => void; readonly onDownload: (s: PBLSubmission) => void; }) { const { t } = useI18n(); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); const title = submission.kind === 'file' && submission.filename ? submission.filename : t('pbl.v2.submission.textSubmission'); return (
e.stopPropagation()} className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-2xl border border-cyan-100/[0.14] bg-background shadow-[0_24px_70px_rgba(6,16,34,0.42)]" role="dialog" aria-modal="true" aria-label={t('pbl.v2.submission.viewSubmission')} >

{title}

{submission.mimeType?.startsWith('image/') && submission.fileUrl ? (
{submission.filename {trimmedPBLText(submission.content) && (
                  {submission.content}
                
)}
) : (
              {submission.content ?? ''}
            
)}
); } interface SubmissionModalProps { project: PBLProjectV2; microtaskId: string; milestoneId: string; microtaskTitle: string; existing: PBLSubmission[]; /** True when a chat stream started while the modal is open — block submit so * it can't interleave with the instructor's in-flight response. */ submitLocked?: boolean; onClose: () => void; onSubmit: (args: { microtaskId: string; milestoneId: string; kind: 'text' | 'file'; content: string; filename?: string; mimeType?: string; fileUrl?: string; }) => void; } function SubmissionModal({ project: _project, microtaskId, milestoneId, microtaskTitle, existing, submitLocked, onClose, onSubmit, }: SubmissionModalProps) { const { t } = useI18n(); // Whether the currently-selected model can read images. Reactive so that // switching models (in Settings) updates the image-caption gating live. const hasVision = useSettingsStore((s) => { const model = findModelById(s.providerId, s.providersConfig[s.providerId]?.models, s.modelId); return !!model?.capabilities?.vision; }); const [mode, setMode] = useState<'paste' | 'file'>('paste'); const [text, setText] = useState(''); const [filename, setFilename] = useState(''); const [mimeType, setMimeType] = useState(''); const [fileUrl, setFileUrl] = useState(undefined); const [parsing, setParsing] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const textareaRef = useRef(null); // Aborts the in-flight file parse / upload so it doesn't keep running in the // background after the modal is closed (which would otherwise occupy the // (serialized) parse backend and stall the next upload). const abortRef = useRef(null); // Tracks whether the current `text` came from a file (text upload / parsed // PDF) rather than being a caption the learner typed. Lets us clear stale // file-derived text when a new file is selected while preserving a typed // image caption. const textFromFileRef = useRef(false); // Confirm before discarding an in-flight parse/upload, then actually abort it. const handleClose = useCallback(() => { if (parsing || uploading) { if (!window.confirm(t('pbl.v2.submission.cancelProcessingConfirm'))) return; abortRef.current?.abort(); } onClose(); }, [parsing, uploading, onClose, t]); // Focus the textarea on mount so the learner can start typing // immediately. Skipping focus when they're in file mode (the file // picker is the natural first action there). useEffect(() => { if (mode === 'paste') { textareaRef.current?.focus(); } }, [mode]); // Abort any in-flight parse/upload when the modal unmounts so it never // continues in the background after close. useEffect(() => () => abortRef.current?.abort(), []); // Esc-to-close as a soft affordance. Click-outside is handled by // the backdrop's onClick (with stopPropagation on the modal body). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [handleClose]); // Drop the previously-selected attachment so a rejected or failed // replacement (oversize / unsupported / parse / upload error) can never // leave the prior file silently staged for submit. A caption the learner // typed by hand is kept; only file-derived text is cleared. const resetAttachment = () => { setFileUrl(undefined); setFilename(''); setMimeType(''); if (textFromFileRef.current) { setText(''); textFromFileRef.current = false; } }; const handleFile = async (file: File) => { setError(null); resetAttachment(); if (file.size > FILE_BYTES_CAP) { setError(t('pbl.v2.submission.fileTooLarge')); return; } // Cancel any prior in-flight parse/upload, then scope this run to a fresh // controller so closing the modal (or starting a replacement) can abort it. abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; // Image / screenshot: store via OpenMAIC's existing object storage // (reused, not modified); fall back to an inline base64 data URL only when // storage is unconfigured (capped to avoid bloating the project). The image // itself is the submission and is fed to a vision-capable evaluator. if (isImageFile(file)) { setUploading(true); try { let src = await uploadBlobToStorage(file, 'media', ac.signal); if (ac.signal.aborted) return; if (!src) { if (file.size > IMAGE_BASE64_CAP) { setError(t('pbl.v2.submission.imageTooLargeNoStorage')); return; } src = await blobToDataUrl(file); if (ac.signal.aborted) return; } setFileUrl(src); setFilename(file.name); setMimeType(file.type || 'image/png'); // Keep any caption the learner already typed (don't clobber it). } catch (e) { if (ac.signal.aborted) return; setError( t('pbl.v2.submission.imageUploadFailed', { error: e instanceof Error ? e.message : String(e), }), ); } finally { if (!ac.signal.aborted) setUploading(false); } return; } // PDF: parse to text via OpenMAIC's existing /api/parse-pdf (reused, not // modified), so the parsed text flows through the unchanged text-based // evaluator. The original PDF is kept at `fileUrl` (object storage, // best-effort) for download/view. if (isPdfFile(file)) { setParsing(true); try { const fd = new FormData(); fd.append('pdf', file); const res = await fetch('/api/parse-pdf', { method: 'POST', body: fd, signal: ac.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); if (ac.signal.aborted) return; const parsed: string = json?.data?.markdown ?? json?.data?.text ?? json?.markdown ?? json?.text ?? ''; if (!trimmedPBLText(parsed)) { setError(t('pbl.v2.submission.pdfNoText')); return; } setText(parsed); textFromFileRef.current = true; setFilename(file.name); setMimeType('application/pdf'); const url = await uploadBlobToStorage(file, 'media', ac.signal); if (ac.signal.aborted) return; setFileUrl(url ?? undefined); } catch (e) { if (ac.signal.aborted) return; setError( t('pbl.v2.submission.parsePdfFailed', { error: e instanceof Error ? e.message : String(e), }), ); } finally { if (!ac.signal.aborted) setParsing(false); } return; } if (!isValidTextFile(file)) { setError(t('pbl.v2.submission.unsupportedFileType')); return; } try { const content = await file.text(); setText(content); textFromFileRef.current = true; setFilename(file.name); setMimeType(file.type || ''); setFileUrl(undefined); } catch (e) { setError( t('pbl.v2.submission.readFileFailed', { error: e instanceof Error ? e.message : String(e), }), ); } }; // Paste a screenshot / image straight from the clipboard. const handlePaste = (e: React.ClipboardEvent) => { if (parsing || uploading) return; const items = e.clipboardData?.items; if (!items) return; for (const it of Array.from(items)) { if (it.type.startsWith('image/')) { const file = it.getAsFile(); if (file) { e.preventDefault(); // Stay in the current tab — the pasted image shows as a thumbnail // chip and any typed text becomes its caption. void handleFile(file); } return; } } }; // An image submission is the picture itself; the textarea is an optional // caption, so it may be submitted with no text. const hasImage = !!fileUrl && mimeType.startsWith('image/'); // On a non-vision model the evaluator can't see the picture, so a pure-image // submission needs a text caption to grade against. const captionRequired = imageRequiresCaption({ hasImage, hasVision, hasCaption: !!text.trim(), }); const clearImage = () => { setFileUrl(undefined); setFilename(''); setMimeType(''); setError(null); }; const handleSubmit = () => { if (!text.trim() && !hasImage) { setError(t('pbl.v2.submission.contentRequired')); return; } if (captionRequired) { setError(t('pbl.v2.submission.imageNeedsCaptionNoVision')); return; } setBusy(true); setError(null); // addSubmission is synchronous; the only "async-ish" thing here // is the upstream onProjectChange + downstream evaluator // invocation (in PR 6.6). Wrapping in a microtask ensures the // disabled button state is rendered before we re-enter. queueMicrotask(() => { try { onSubmit({ microtaskId, milestoneId, kind: fileUrl || mode === 'file' ? 'file' : 'text', content: text, filename: filename || undefined, mimeType: mimeType || undefined, fileUrl: fileUrl || undefined, }); } catch (e) { setError(e instanceof Error ? e.message : String(e)); setBusy(false); } }); }; return (
e.stopPropagation()} className="flex max-h-[85vh] w-full max-w-xl flex-col rounded-2xl border border-cyan-100/[0.14] bg-background shadow-[0_24px_70px_rgba(6,16,34,0.42)]" role="dialog" aria-modal="true" aria-label={t('pbl.v2.submission.submitOutput')} > {/* Header */}

{t('pbl.v2.submission.modalTitle')}

{t('pbl.v2.submission.modalTaskLabel', { title: microtaskTitle })}
{/* Body */}
{/* Mode toggle */}
{(['paste', 'file'] as const).map((m) => ( ))}
{mode === 'file' && ( )} {hasImage && (
{filename} {filename}
)} {captionRequired && (
{t('pbl.v2.submission.imageNeedsCaptionNoVision')}
)}