'use client'; /** * PBL v2 — Workspace chat panel. * * Renders the Instructor's thread, the live streaming draft, the * input box, and the error banner. Multi-line input by default * (Enter sends, Shift+Enter inserts newline) so learners can paste * code snippets. * * The Instructor is the only agent wired today. The component accepts * an optional `agentName` so AgentTabs can label agents distinctly if * additional roles are introduced later. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ArrowUp, CheckCircle2, ChevronDown, ChevronRight, Drama, Lightbulb, Loader2, MessageSquare, } from 'lucide-react'; import { AvatarDisplay } from '@/components/ui/avatar-display'; import { SpeechButton } from '@/components/audio/speech-button'; import type { PBLChatMessage, PBLEvaluation, PBLProjectV2, PBLScenarioCharacter, } from '@/lib/pbl/v2/types'; import { trimmedPBLText } from '@/lib/pbl/v2/readers'; import { normalizeProjectRuntime, PBL_SIMULATOR_AGENT_ID, } from '@/lib/pbl/v2/operations/kernel/progress'; import { appendRuntimeEvent, milestoneIdForMicrotask, mintRuntimeEventId, transitionProjectUiPhase, } from '@/lib/pbl/v2/operations/kernel/runtime-events'; import { stripEvaluationTail } from '@/lib/pbl/v2/operations/runtime/eval-tail-parser'; import { isTaskCompletionReadyMessageContent } from '@/lib/pbl/v2/operations/kernel/task-completion'; import { cn } from '@/lib/utils/cn'; import { useInstructorStream, type StreamDisplayState } from './use-instructor-stream'; import { instructorIntroText } from './instructor-intro'; import { useI18n } from '@/lib/hooks/use-i18n'; import { MarkdownText } from './markdown-text'; import { TaskEvaluationCard } from './eval-cards/task-evaluation-card'; import { MilestoneCard } from './eval-cards/milestone-card'; import { CompletionCtaCard } from './eval-cards/completion-cta-card'; import type { SubmissionEvaluationStatus } from './submission'; import { MILESTONE_DIVIDER_PREFIX, TASK_DIVIDER_PREFIX, stripEmbeddedDividerMarkers, } from './protocol-markers'; interface Props { readonly project: PBLProjectV2; readonly onProjectChange: (next: PBLProjectV2) => void; readonly agentName?: string; readonly submissionEvaluationStatus?: SubmissionEvaluationStatus | null; /** True when an Instructor / evaluator stream is in flight — including one * started before this chat instance mounted (the learner left to the Hero * and came back mid-stream). Lets the remounted chat show the "thinking…" * indicator even though its own `streaming` state starts fresh. */ readonly instructorStreaming: boolean; /** Reports stream start/end up to a parent that outlives this chat, so the * flag above survives a Hero ↔ workspace remount. */ readonly onInstructorStreamingChange: (active: boolean) => void; /** Live tokens from a stream owned by the workspace shell, such as the * sidebar "Complete" flow that chains milestone evaluation and next-task * opener outside this chat hook. */ readonly externalStream?: StreamDisplayState | null; } function newClientMessageId(): string { return 'msg_local_' + Date.now().toString(16) + Math.random().toString(16).slice(2, 6); } /** Append a user message to the Instructor thread in a fresh clone. */ function appendUserMessage( project: PBLProjectV2, instructorAgentId: string | undefined, text: string, microtaskId: string | undefined, ): PBLProjectV2 { const next = structuredClone(project); normalizeProjectRuntime(next); const thread = next.threads.find((t) => t.agentId === instructorAgentId); if (!thread) return next; const message: PBLChatMessage = { id: newClientMessageId(), roleType: 'user', content: text, ts: new Date().toISOString(), microtaskId, }; thread.messages.push(message); appendRuntimeEvent(next, { id: mintRuntimeEventId(), kind: 'message_created', actorType: 'user', messageId: message.id, threadId: thread.agentId, ts: message.ts, microtaskId: message.microtaskId, milestoneId: milestoneIdForMicrotask(next, message.microtaskId), }); next.updatedAt = new Date().toISOString(); return next; } function currentMicrotaskId(project: PBLProjectV2): string | undefined { const ms = project.milestones.find((m) => m.status === 'active'); return ms?.microtasks.find((t) => t.status === 'todo' || t.status === 'in_progress')?.id; } const MIN_ROWS = 1; const MAX_INPUT_HEIGHT_PX = 200; const TASK_READY_TYPEWRITER_DONE_DELAY_MS = 700; export function PBLV2Chat({ project, onProjectChange, agentName, submissionEvaluationStatus, instructorStreaming, onInstructorStreamingChange, externalStream, }: Props) { const [input, setInput] = useState(''); const textareaRef = useRef(null); const scrollEndRef = useRef(null); const autoGreetingRef = useRef(null); const scenarioStageOpenerRef = useRef(null); const seenMessageAgentIdRef = useRef(undefined); const seenMessageIdsRef = useRef | null>(null); const animatingTaskReadyMessageIdsRef = useRef>(new Set()); const [taskReadyTypewriterIds, setTaskReadyTypewriterIds] = useState>( () => new Set(), ); // Cursor position for the handover hint tooltip (only tracked while a // stage handover is pending; null when not hovering / not pending). const [handoverHintPos, setHandoverHintPos] = useState<{ x: number; y: number } | null>(null); const { streaming, status, draftAssistant, streamCommittedOutput, error, simPhase, run, clearError, } = useInstructorStream(project, onProjectChange, onInstructorStreamingChange); const { t } = useI18n(); // `streaming` is this instance's own run; `instructorStreaming` also covers a // run that started before this chat (re)mounted (learner went to the Hero and // came back mid-stream). Treat either as "busy" so the indicator shows and the // learner can't fire a second, overlapping turn. const streamActive = streaming || instructorStreaming; const visibleExternalStream = !streaming && externalStream && externalStream.status !== 'idle' ? externalStream : null; const visibleDraftAssistant = streaming ? draftAssistant : (visibleExternalStream?.draftAssistant ?? ''); const visibleStreamStatus = streaming ? status : (visibleExternalStream?.status ?? 'instructor'); const visibleStreamCommittedOutput = streaming ? streamCommittedOutput : (visibleExternalStream?.streamCommittedOutput ?? streamCommittedOutput); const showStreamingDraft = shouldShowStreamingDraft({ streaming, instructorStreaming, draftAssistant: visibleDraftAssistant, streamCommittedOutput: visibleStreamCommittedOutput, hasExternalDraft: !!visibleExternalStream, submissionEvaluationActive: !!submissionEvaluationStatus, }); const chatBusy = streamActive || !!submissionEvaluationStatus; const projectCompleted = project.status === 'completed'; // A milestone finished and the learner must click "Continue to Next // Stage" before the next milestone's first microtask activates. While // this is pending there is NO active microtask, so the instructor // endpoint would return NO_ACTIVE_MICROTASK — gate the input here and // point the learner at the Continue button instead. const handoverPending = !!project.pendingHandover && !project.pendingHandover.consumed; /** * Handler for the MilestoneCard's "继续到下一阶段" button. * * 1. Server-side: POST /api/pbl/v2/task/update with action * continue_handover. The server calls continueAfterHandover() * which flips the next milestone from LOCKED → ACTIVE, marks * the first microtask in_progress, and stamps the handover as * `consumed`. * 2. Client-side: take the returned project and immediately fire * a SETUP-phase opener via /api/pbl/v2/open-task — that's the * "Instructor speaks first when a new task activates" UX, same * as the GREETING path in the Hero. * * The two requests are sequential, not parallel: we MUST persist * the continue_handover mutation before SETUP-opening, otherwise * the SETUP turn would still see the old `pendingHandover` and * the next microtask wouldn't be in_progress yet. * * Disabled while `streaming` so the learner can't double-click * across an in-flight Instructor turn. */ const handleContinueHandover = async () => { if (chatBusy) return; try { const res = await fetch('/api/pbl/v2/task/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project, action: 'continue_handover' }), }); if (!res.ok) { const t = await res.text(); throw new Error(`HTTP ${res.status}: ${t.slice(0, 200)}`); } const data = (await res.json()) as { ok?: boolean; data?: { project: PBLProjectV2 }; project?: PBLProjectV2; }; // apiSuccess wraps payload under .data; tolerate both shapes // so a future server refactor doesn't break the client. const nextProject: PBLProjectV2 | undefined = data.data?.project ?? data.project; if (!nextProject) { throw new Error('continue_handover response missing project'); } onProjectChange(nextProject); // Open the freshly-activated milestone. // - SCENARIO projects: the stage opener (Simulator scene / Instructor // wrapup) is fired path-independently by the scenario stage-opener // effect above — NOT here — so that the sidebar "continue" control // (which is the actual control for scenario stage transitions, since // roleplay stages produce no milestone card) opens the next stage too. // - Ordinary projects: open the next milestone with an Instructor SETUP // turn exactly as before. if (!nextProject.scenario) { void run({ endpoint: '/api/pbl/v2/open-task', body: { phase: 'setup' }, initialProject: nextProject, }); } } catch (e) { // The MilestoneCard doesn't render errors itself — surface // through the chat's existing error banner so the learner // has one consistent place to look. const msg = e instanceof Error ? e.message : String(e); // Surface via the existing setError flow inside the hook — // but the hook's setError is private. Cheapest path: log + // visible alert. Alert is jarring but rare (network error // mid-Continue); better than a silent failure. console.error('[PBL v2] continue_handover failed:', msg); window.alert(t('pbl.v2.chat.continueError', { error: msg })); } }; const instructorRole = useMemo( () => project.roles.find((r) => r.type === 'instructor'), [project.roles], ); // SCENARIO ONLY. During a roleplay stage the chat is driven by the // Simulator (the in-character cast), not the Instructor: it reads the // Simulator thread and posts to /simulator. prep / wrapup / every // ordinary project keep the Instructor thread + /instructor exactly as // before (the flag below is always false without `project.scenario`). const activeMilestone = useMemo( () => project.milestones.find((m) => m.status === 'active'), [project.milestones], ); // SCENARIO ONLY. During a stage handover the just-completed milestone is no // longer `active` and the next one is still `locked`, so there is momentarily // NO active milestone. Without anchoring, the view would fall back to the // Instructor (prep) thread mid-scene — jarring inside a roleplay run. Anchor // the displayed stage to the just-completed milestone during that gap, so a // roleplay→roleplay advance stays inside the scene (simulator thread + cast // banner). Ordinary projects never have a scenario, so this is a no-op there. const completedHandoverMilestone = useMemo(() => { const h = project.pendingHandover; if (!project.scenario || !h || h.consumed) return undefined; return project.milestones.find((m) => m.id === h.completedMilestoneId); }, [project.scenario, project.pendingHandover, project.milestones]); const stageMilestone = activeMilestone ?? completedHandoverMilestone; const isRoleplay = !!project.scenario && stageMilestone?.scenarioStage === 'roleplay'; const roleplayCharacter: PBLScenarioCharacter | undefined = isRoleplay ? project.scenario?.characters?.[0] : undefined; const scenarioCharacters = project.scenario?.characters; const activeAgentId = isRoleplay ? PBL_SIMULATOR_AGENT_ID : instructorRole?.id; const messages = useMemo(() => { const thread = project.threads.find((t) => t.agentId === activeAgentId); return thread?.messages ?? []; }, [project.threads, activeAgentId]); // SCENARIO ONLY. Once the learner is OUT of the roleplay scene (i.e. the chat // is showing the Instructor thread again — wrapup, or back here from the // completion page) AND a roleplay act actually happened, fold the whole // simulator-thread conversation into ONE collapsible block embedded in the // Instructor timeline. This stitches prep ↔ roleplay ↔ wrapup back into one // continuous, readable history instead of the scene vanishing on wrapup. // Empty (→ no block) for ordinary projects, during prep, and inside roleplay. const roleplayHistory = useMemo(() => { if (!project.scenario || isRoleplay) return []; const playedRoleplay = project.milestones.some( (m) => m.scenarioStage === 'roleplay' && m.status === 'completed', ); if (!playedRoleplay) return []; const sim = project.threads.find((t) => t.agentId === PBL_SIMULATOR_AGENT_ID); return (sim?.messages ?? []).filter((m) => trimmedPBLText(m.content)); }, [project.scenario, project.milestones, project.threads, isRoleplay]); const [roleplayHistoryOpen, setRoleplayHistoryOpen] = useState(false); useLayoutEffect(() => { const seen = seenMessageIdsRef.current; if (!seen || seenMessageAgentIdRef.current !== activeAgentId) { seenMessageAgentIdRef.current = activeAgentId; seenMessageIdsRef.current = new Set(messages.map((message) => message.id)); return; } const newlyAnimatedIds: string[] = []; for (const message of messages) { if (!message.id || seen.has(message.id)) continue; seen.add(message.id); if ( message.roleType === 'instructor' && isTaskCompletionReadyMessageContent(message.content) && !animatingTaskReadyMessageIdsRef.current.has(message.id) ) { newlyAnimatedIds.push(message.id); } } if (!newlyAnimatedIds.length) return; setTaskReadyTypewriterIds((prev) => { const next = new Set(prev); for (const id of newlyAnimatedIds) { next.add(id); animatingTaskReadyMessageIdsRef.current.add(id); } return next; }); }, [messages, activeAgentId]); const handleTaskReadyTypewriterComplete = useCallback((messageId: string) => { window.setTimeout(() => { setTaskReadyTypewriterIds((prev) => { if (!prev.has(messageId)) return prev; const next = new Set(prev); next.delete(messageId); return next; }); }, TASK_READY_TYPEWRITER_DONE_DELAY_MS); }, []); // Agent speaks first when its thread is empty so the learner never // lands in a silent chat. For the active agent: // - roleplay stage → the Simulator opens the scene (system narration // + the character's first line) via /simulator greeting; // - everything else → the Instructor greeting via /open-task. // Keyed on the active agent so entering a roleplay stage triggers its // own scene opener exactly once. Ordinary projects only ever hit the // instructor branch (isRoleplay is always false without a scenario). useEffect(() => { if (project.uiPhase !== 'workspace') return; if (!activeAgentId) return; // `instructorStreaming` (ref-counted in the renderer) also guards a // run in flight from another instance, so we never double-open. if (messages.length > 0 || draftAssistant || streaming || instructorStreaming) return; const key = `${project.createdAt}:${activeAgentId}`; if (autoGreetingRef.current === key) return; autoGreetingRef.current = key; if (isRoleplay) { void run({ endpoint: '/api/pbl/v2/simulator', body: { phase: 'greeting' } }); return; } const initialProject = structuredClone(project); const priorQuizResults = initialProject.pendingOpenTaskPriorQuizResults; if (priorQuizResults) { delete initialProject.pendingOpenTaskPriorQuizResults; onProjectChange(initialProject); } void run({ endpoint: '/api/pbl/v2/open-task', body: priorQuizResults && priorQuizResults.length > 0 ? { phase: 'greeting', priorQuizResults } : { phase: 'greeting' }, initialProject, }); }, [ project.uiPhase, project.createdAt, project, activeAgentId, isRoleplay, messages.length, draftAssistant, streaming, instructorStreaming, run, onProjectChange, ]); // SCENARIO ONLY. Stage opener for roleplay / wrapup stages. The sidebar // "continue" / "enter scene" controls (and the enter_scenario route) only // MUTATE the project — they fire no opener turn — and roleplay stages produce // no milestone-eval card to carry one. The handover also seeds a divider into // the target thread, so the empty-thread auto-greeting above can't fire here // either. This effect is therefore the single, path-independent source of the // in-scene opener: when a roleplay/wrapup stage is active but its opener has // not run yet (no character / Instructor message tagged to THIS stage's beats, // dividers excluded), fire it exactly once. Strictly gated on `project.scenario` // so ordinary projects never reach it. useEffect(() => { if (!project.scenario || project.uiPhase !== 'workspace') return; if (draftAssistant || streaming || instructorStreaming) return; const active = project.milestones.find((m) => m.status === 'active'); const stage = active?.scenarioStage; if (!active || (stage !== 'roleplay' && stage !== 'wrapup')) return; const beatIds = new Set(active.microtasks.map((task) => task.id)); const wantRole = stage === 'roleplay' ? 'simulator' : 'instructor'; const threadAgentId = stage === 'roleplay' ? PBL_SIMULATOR_AGENT_ID : instructorRole?.id; const thread = project.threads.find((th) => th.agentId === threadAgentId); const opened = (thread?.messages ?? []).some( (m) => m.roleType === wantRole && !!m.microtaskId && beatIds.has(m.microtaskId) && !m.content.startsWith(MILESTONE_DIVIDER_PREFIX) && !m.content.startsWith(TASK_DIVIDER_PREFIX), ); if (opened) return; if (scenarioStageOpenerRef.current === active.id) return; scenarioStageOpenerRef.current = active.id; void run( stage === 'roleplay' ? { endpoint: '/api/pbl/v2/simulator', body: { phase: 'greeting' } } : { endpoint: '/api/pbl/v2/open-task', body: { phase: 'setup' } }, ); }, [project, draftAssistant, streaming, instructorStreaming, instructorRole?.id, run]); // Interleave evaluations into the chat feed by timestamp. Task / // milestone evals are part of the conversation flow (each is a // reaction to something the learner did) so the visual story // requires them to appear in the right order — not in a separate // panel. Final evaluations live in chat too, *next to* a CTA card. // // We merge messages + evaluations into one timeline keyed by // `createdAt` / `ts`. Ties resolved by item kind preference: // message < evaluation (eval after message of same instant) // // Rendered items are typed so the render switch can branch // cleanly on `.kind`. const timeline = useMemo( () => buildTimeline(messages, project.evaluations, roleplayHistory), [messages, project.evaluations, roleplayHistory], ); // Auto-grow textarea as the learner types. useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = 'auto'; el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT_PX)}px`; }, [input]); // Auto-scroll to bottom on new tokens / new messages. useEffect(() => { scrollEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, draftAssistant, submissionEvaluationStatus]); const handleSend = () => { const text = input.trim(); if (!text || chatBusy || projectCompleted || handoverPending) return; setInput(''); // Optimistic local append — show the user's message immediately // so they don't feel like the chat froze while the LLM warms up. // The server intentionally does NOT echo the user message back as // a project_patch (see lib/pbl/v2/agents/instructor.ts), so this // optimistic append is the single source of truth for it. const microtaskId = currentMicrotaskId(project); const withUser = appendUserMessage(project, activeAgentId, text, microtaskId); onProjectChange(withUser); if (isRoleplay) { void run({ endpoint: '/api/pbl/v2/simulator', body: { userMessage: text, phase: 'instructing' }, initialProject: withUser, }); return; } void run({ endpoint: '/api/pbl/v2/instructor', body: { userMessage: text, phase: 'instructing' }, initialProject: withUser, }); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Enter' || e.nativeEvent.isComposing) return; if (e.shiftKey) return; e.preventDefault(); handleSend(); }; // In a roleplay stage the streaming/label name is the character; the // per-message bubble still resolves its own speaker by characterId. const displayName = displayAgentName( isRoleplay ? (roleplayCharacter?.name ?? agentName) : (agentName ?? instructorRole?.name), isRoleplay ? 'Character' : 'Instructor', ); // Learner-facing intro shown on hover of the instructor avatar; falls back to // the role name when there's no curated intro. const agentIntro = instructorIntroText(instructorRole); return (
{messages.length === 0 && !draftAssistant && !streamActive && ( )} {timeline.map((item) => { if (item.kind === 'message') { return ( ); } if (item.kind === 'roleplay-history') { return ( setRoleplayHistoryOpen((v) => !v)} title={t('pbl.v2.chat.roleplayHistoryTitle')} /> ); } // item.kind === 'evaluation' const ev = item.evaluation; if (ev.kind === 'task') { // Task eval is a distinct review moment, not a normal // chat reply. It stays in the same timeline but uses a // subtle accent treatment so learners can scan it apart // from instructor guidance. const feedback = stripTailForDisplay(ev.feedback); return (
{displayName} · {t('pbl.v2.taskEvalCard.title')}
{feedback && ( )}
); } if (ev.kind === 'milestone') { // Milestone card is full-width; pull it out of the // narrow chat-bubble cell so the reflection feels like // a distinct moment, not another message. return ( ); } if (ev.kind === 'final') { // Final eval feeds the dedicated completion report. In chat, keep // only the entry-point card so the same summary is not repeated // once here and again on the report page. return (
onProjectChange(transitionProjectUiPhase(project, 'completed'))} />
); } return null; })} {submissionEvaluationStatus && ( )} {showStreamingDraft && ( // When the stream belongs to THIS instance, show its live status + // tokens. When it's only a background stream resumed across a remount // (own `streaming` false), the live tokens live in the unmounted // instance and can't be recovered — fall back to a generic // Instructor "thinking…" bubble so the learner still knows to wait; // the finished message lands via the store when the stream settles. // Suppressed while a submission evaluation is showing its own richer // bubble (below) so the two don't stack. )} {error && (
{error}
)}