1399 lines
53 KiB
TypeScript
1399 lines
53 KiB
TypeScript
'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<string> {
|
|
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<void> {
|
|
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<string, string>();
|
|
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<PBLSubmission | null>(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<string | null>(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<SubmissionEvaluationStatus>) => {
|
|
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<string, unknown>,
|
|
streamStatus: StreamStatus,
|
|
onPatch?: (patch: Extract<PBLSSEEvent, { type: 'project_patch' }>['patch']) => void,
|
|
) => {
|
|
let draft = '';
|
|
setEvaluationStatus({
|
|
phase: streamStatus === 'instructor' ? 'followup' : 'evaluating',
|
|
streamStatus,
|
|
draft,
|
|
});
|
|
const headers: Record<string, string> = {
|
|
'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 (
|
|
<aside className="pbl-v2-scroll-fade h-full space-y-4 overflow-y-auto p-4">
|
|
{current ? (
|
|
<>
|
|
<div className="rounded-xl border border-cyan-100/[0.12] bg-slate-800/[0.46] p-4 shadow-[0_12px_30px_rgba(6,16,34,0.20)]">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
|
|
{t('pbl.v2.submission.currentTask')}
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-white">
|
|
{display?.title ?? current.task.title}
|
|
</h3>
|
|
{/* SCENARIO: prefer the learner-facing brief (what + why, no answer)
|
|
over the raw `description` (which doubles as the character's
|
|
established-fact source and may read as a bare action). Under a
|
|
roleplay act this is the merged whole-act brief. Falls back to
|
|
`description` for ordinary projects / older scenarios. */}
|
|
{display?.brief && (
|
|
<p className="text-xs text-muted-foreground mt-1.5 whitespace-pre-wrap">
|
|
{display.brief}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{display && display.hints.length > 0 && (
|
|
<div className="rounded-xl border border-cyan-100/[0.12] bg-slate-800/[0.40] p-4">
|
|
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-cyan-200/80 mb-2">
|
|
<Lightbulb className="w-3 h-3" />
|
|
{t('pbl.v2.submission.hints')}
|
|
</div>
|
|
<ul className="space-y-1.5 text-xs text-foreground/80">
|
|
{display.hints.map((hint, i) => (
|
|
<li key={i} className="flex gap-2">
|
|
<span className="text-muted-foreground shrink-0">{i + 1}.</span>
|
|
<span>{hint}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-xl border border-cyan-100/[0.12] bg-slate-800/[0.46] p-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!submitLocked) setModalOpen(true);
|
|
}}
|
|
disabled={submitLocked}
|
|
className="flex w-full items-center justify-center gap-2 rounded-xl border border-primary/30 bg-primary px-3 py-2 text-xs font-medium text-primary-foreground shadow-[0_0_28px_rgba(155,124,255,0.26)] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<Upload className="w-3.5 h-3.5" />
|
|
{t('pbl.v2.submission.submitOutput')}
|
|
</button>
|
|
{evaluating ? (
|
|
<div className="mt-2 text-[10px] text-muted-foreground text-center">
|
|
{t('pbl.v2.submission.evaluating')}
|
|
</div>
|
|
) : (
|
|
instructorStreaming && (
|
|
<div className="mt-2 text-[10px] text-muted-foreground text-center">
|
|
{t('pbl.v2.submission.lockedWhileStreaming')}
|
|
</div>
|
|
)
|
|
)}
|
|
{existing.length > 0 && (
|
|
<div className="mt-3 text-[10px] text-muted-foreground text-center">
|
|
{t('pbl.v2.submission.submittedCount', { count: existing.length })}
|
|
</div>
|
|
)}
|
|
{evalError && (
|
|
<div
|
|
role="alert"
|
|
className="mt-2 rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-[11px] leading-snug text-destructive"
|
|
>
|
|
{t('pbl.v2.submission.evalFailed', { error: evalError })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="rounded-xl border border-cyan-100/[0.12] bg-slate-800/[0.40] p-4 text-xs text-muted-foreground">
|
|
{t('pbl.v2.submission.noActiveTask')}
|
|
</div>
|
|
)}
|
|
|
|
{allSubmissions.length > 0 && (
|
|
<SubmissionHistory
|
|
submissions={allSubmissions}
|
|
taskTitleById={taskTitleById}
|
|
onView={(s) => setViewer(s)}
|
|
onDownload={downloadSubmission}
|
|
/>
|
|
)}
|
|
|
|
{modalOpen && current && (
|
|
<SubmissionModal
|
|
project={project}
|
|
microtaskId={current.task.id}
|
|
milestoneId={current.milestone.id}
|
|
microtaskTitle={current.task.title}
|
|
existing={existing}
|
|
submitLocked={submitLocked}
|
|
onClose={() => setModalOpen(false)}
|
|
onSubmit={(args) => {
|
|
// Apply the submission to a clone so React picks up the
|
|
// change; addSubmission mutates the project, so we clone
|
|
// first to keep render semantics clean.
|
|
const next = structuredClone(project);
|
|
const sub = addSubmission(next, args);
|
|
appendSubmissionReceiptMessage(next, sub);
|
|
onProjectChange(next);
|
|
onSubmissionAdded?.(sub, args.microtaskId);
|
|
setModalOpen(false);
|
|
void runImmediateTaskEval(next, args.milestoneId, args.microtaskId, current.task.title);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{viewer && (
|
|
<SubmissionViewer
|
|
submission={viewer}
|
|
onClose={() => setViewer(null)}
|
|
onDownload={downloadSubmission}
|
|
/>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function SubmissionHistory({
|
|
submissions,
|
|
taskTitleById,
|
|
onView,
|
|
onDownload,
|
|
}: {
|
|
readonly submissions: PBLSubmission[];
|
|
readonly taskTitleById: Map<string, string>;
|
|
readonly onView: (s: PBLSubmission) => void;
|
|
readonly onDownload: (s: PBLSubmission) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div className="rounded-xl border border-cyan-100/[0.12] bg-slate-800/[0.38] p-3">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
|
|
{t('pbl.v2.submission.submissionHistory')}
|
|
</div>
|
|
<ul className="space-y-1.5">
|
|
{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 (
|
|
<li key={s.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => (viewable ? onView(s) : onDownload(s))}
|
|
title={
|
|
viewable ? t('pbl.v2.submission.viewSubmission') : t('pbl.v2.submission.download')
|
|
}
|
|
className="w-full rounded-lg border border-cyan-100/[0.12] bg-slate-700/[0.32] px-2.5 py-1.5 text-left text-[11px] leading-snug text-foreground/80 transition-colors hover:bg-slate-700/[0.46] focus:outline-none focus:ring-1 focus:ring-primary/40"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
{isImageSub ? (
|
|
<ImageIcon className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
) : isFile ? (
|
|
<Paperclip className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<FileText className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
|
|
{viewable ? (
|
|
<Eye className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<Download className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground/75">
|
|
{taskTitle && (
|
|
<span className="min-w-0 truncate">
|
|
{t('pbl.v2.submission.fromTask', { title: taskTitle })}
|
|
</span>
|
|
)}
|
|
<span className="ml-auto shrink-0">
|
|
{new Date(s.createdAt).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
onClick={onClose}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/48 p-4 backdrop-blur-md"
|
|
>
|
|
<div
|
|
onClick={(e) => 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')}
|
|
>
|
|
<div className="flex items-center justify-between gap-3 border-b border-cyan-100/[0.12] px-5 py-4">
|
|
<h3 className="min-w-0 flex-1 truncate text-base font-semibold leading-tight">{title}</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDownload(submission)}
|
|
className="text-muted-foreground hover:text-foreground p-1"
|
|
aria-label={t('pbl.v2.submission.download')}
|
|
title={t('pbl.v2.submission.download')}
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-muted-foreground hover:text-foreground p-1 -m-1"
|
|
aria-label={t('pbl.v2.hero.close')}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="pbl-v2-scroll-fade flex-1 overflow-y-auto px-5 py-4">
|
|
{submission.mimeType?.startsWith('image/') && submission.fileUrl ? (
|
|
<div className="space-y-3">
|
|
<img
|
|
src={submission.fileUrl}
|
|
alt={submission.filename || 'submission'}
|
|
className="mx-auto max-h-[60vh] rounded-lg border border-cyan-100/[0.14] object-contain"
|
|
/>
|
|
{trimmedPBLText(submission.content) && (
|
|
<pre className="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-foreground/90">
|
|
{submission.content}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<pre className="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-foreground/90">
|
|
{submission.content ?? ''}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string | undefined>(undefined);
|
|
const [parsing, setParsing] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(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<AbortController | null>(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 (
|
|
<div
|
|
onClick={handleClose}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/48 p-4 backdrop-blur-md"
|
|
>
|
|
<div
|
|
onClick={(e) => 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 */}
|
|
<div className="flex items-start justify-between gap-3 border-b border-cyan-100/[0.12] px-5 py-4">
|
|
<div className="min-w-0">
|
|
<h3 className="text-base font-semibold leading-tight">
|
|
{t('pbl.v2.submission.modalTitle')}
|
|
</h3>
|
|
<div className="text-xs text-muted-foreground mt-1 truncate">
|
|
{t('pbl.v2.submission.modalTaskLabel', { title: microtaskTitle })}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className="text-muted-foreground hover:text-foreground p-1 -m-1"
|
|
aria-label={t('pbl.v2.hero.close')}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div
|
|
className="pbl-v2-scroll-fade flex-1 overflow-y-auto px-5 py-4 space-y-3"
|
|
onPaste={handlePaste}
|
|
>
|
|
{/* Mode toggle */}
|
|
<div className="inline-flex rounded-full border border-cyan-100/[0.12] bg-slate-700/[0.26] p-1">
|
|
{(['paste', 'file'] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => setMode(m)}
|
|
className={`px-4 py-1.5 text-xs font-medium rounded-full transition-colors ${
|
|
mode === m
|
|
? 'bg-primary/90 shadow-sm text-primary-foreground'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
type="button"
|
|
>
|
|
{m === 'paste' ? t('pbl.v2.submission.pasteTab') : t('pbl.v2.submission.uploadTab')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{mode === 'file' && (
|
|
<label className="block cursor-pointer rounded-xl border border-dashed border-cyan-100/[0.14] bg-slate-700/[0.24] px-4 py-4 text-center text-xs text-muted-foreground transition-colors hover:bg-slate-700/[0.34]">
|
|
<input
|
|
type="file"
|
|
accept={TEXT_PDF_IMAGE_ACCEPT}
|
|
className="hidden"
|
|
disabled={parsing || uploading}
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) void handleFile(f);
|
|
}}
|
|
/>
|
|
{parsing ? (
|
|
<span className="text-foreground">{t('pbl.v2.submission.parsingPdf')}</span>
|
|
) : uploading ? (
|
|
<span className="text-foreground">{t('pbl.v2.submission.processing')}</span>
|
|
) : hasImage ? (
|
|
<span className="text-foreground">
|
|
{t('pbl.v2.submission.imageSelectedReplace')}
|
|
</span>
|
|
) : filename ? (
|
|
<span className="text-foreground">
|
|
{t('pbl.v2.submission.fileRead', {
|
|
filename,
|
|
size: (text.length / 1024).toFixed(1),
|
|
})}
|
|
</span>
|
|
) : (
|
|
<span>{t('pbl.v2.submission.selectFileHint')}</span>
|
|
)}
|
|
</label>
|
|
)}
|
|
|
|
{hasImage && (
|
|
<div className="flex items-center gap-2.5 rounded-xl border border-cyan-100/[0.14] bg-slate-700/[0.24] p-2">
|
|
<img
|
|
src={fileUrl}
|
|
alt={filename}
|
|
className="h-14 w-14 shrink-0 rounded-md border border-cyan-100/[0.14] object-cover"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-xs text-foreground">{filename}</span>
|
|
<button
|
|
type="button"
|
|
onClick={clearImage}
|
|
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-slate-700/[0.4] hover:text-foreground"
|
|
aria-label={t('pbl.v2.submission.removeImage')}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{captionRequired && (
|
|
<div className="rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 py-2 text-[11px] leading-snug text-amber-200/90">
|
|
{t('pbl.v2.submission.imageNeedsCaptionNoVision')}
|
|
</div>
|
|
)}
|
|
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={text}
|
|
onChange={(e) => {
|
|
setText(e.target.value);
|
|
textFromFileRef.current = false;
|
|
}}
|
|
rows={mode === 'file' ? 8 : 10}
|
|
placeholder={
|
|
hasImage
|
|
? t('pbl.v2.submission.imageCaptionPlaceholder')
|
|
: mode === 'file'
|
|
? t('pbl.v2.submission.uploadPlaceholder')
|
|
: t('pbl.v2.submission.pastePlaceholder')
|
|
}
|
|
className={`w-full resize-y rounded-xl border border-cyan-100/[0.13] bg-slate-950/[0.26] p-3 text-xs leading-relaxed text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/35 ${
|
|
mode === 'paste' ? 'font-mono' : ''
|
|
}`}
|
|
/>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="text-xs px-3 py-2 rounded-md bg-destructive/10 text-destructive border border-destructive/30"
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{existing.length > 0 && (
|
|
<div className="pt-3 border-t">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-2">
|
|
{t('pbl.v2.submission.submittedBadge', { count: existing.length })}
|
|
</div>
|
|
<ul className="space-y-2">
|
|
{existing
|
|
.slice()
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
.map((s) => (
|
|
<li
|
|
key={s.id}
|
|
className="rounded-lg border border-cyan-100/[0.12] bg-slate-700/[0.32] px-2.5 py-2 text-[11px] leading-snug"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
{s.mimeType?.startsWith('image/') ? (
|
|
<>
|
|
<ImageIcon className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 truncate font-medium">
|
|
{s.filename || t('pbl.v2.submission.textSubmission')}
|
|
</span>
|
|
</>
|
|
) : s.kind === 'file' && s.filename ? (
|
|
<>
|
|
<Paperclip className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 truncate font-medium">{s.filename}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<FileText className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
<span className="font-medium">
|
|
{t('pbl.v2.submission.textSubmission')}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
|
{new Date(s.createdAt).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{trimmedPBLText(s.content) && (
|
|
<p className="mt-1 text-muted-foreground/90 line-clamp-2">
|
|
{(s.content ?? '').slice(0, 200)}
|
|
{(s.content ?? '').length > 200 ? '…' : ''}
|
|
</p>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end gap-2 border-t border-cyan-100/[0.12] bg-slate-700/[0.22] px-5 py-3">
|
|
{submitLocked && (
|
|
<span className="mr-auto text-[11px] leading-snug text-muted-foreground">
|
|
{t('pbl.v2.submission.lockedWhileStreaming')}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className="rounded-lg border border-cyan-100/[0.12] bg-slate-700/[0.24] px-3 py-1.5 text-xs transition-colors hover:bg-slate-700/[0.34]"
|
|
>
|
|
{t('pbl.v2.submission.canceling')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={
|
|
busy ||
|
|
parsing ||
|
|
uploading ||
|
|
submitLocked ||
|
|
(!text.trim() && !hasImage) ||
|
|
captionRequired
|
|
}
|
|
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
{busy ? t('pbl.v2.submission.submitting') : t('pbl.v2.submission.submitOutput')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|