Files
openmaic/OpenMAIC/components/scene-renderers/pbl/v2/chat.tsx
T

1554 lines
61 KiB
TypeScript

'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<HTMLTextAreaElement>(null);
const scrollEndRef = useRef<HTMLDivElement>(null);
const autoGreetingRef = useRef<string | null>(null);
const scenarioStageOpenerRef = useRef<string | null>(null);
const seenMessageAgentIdRef = useRef<string | undefined>(undefined);
const seenMessageIdsRef = useRef<Set<string> | null>(null);
const animatingTaskReadyMessageIdsRef = useRef<Set<string>>(new Set());
const [taskReadyTypewriterIds, setTaskReadyTypewriterIds] = useState<Set<string>>(
() => 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<PBLChatMessage[]>(() => {
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<PBLChatMessage[]>(() => {
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<TimelineItem[]>(
() => 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<HTMLTextAreaElement>) => {
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 (
<div className="flex h-full flex-col overflow-hidden">
<main className="pbl-v2-scroll-fade flex-1 space-y-3 overflow-y-auto px-6 py-5">
{messages.length === 0 && !draftAssistant && !streamActive && (
<EmptyChatPlaceholder agentName={displayName} />
)}
{timeline.map((item) => {
if (item.kind === 'message') {
return (
<MessageBubble
key={item.message.id}
message={item.message}
agentName={displayName}
characters={scenarioCharacters}
agentIntro={agentIntro}
typewriter={taskReadyTypewriterIds.has(item.message.id)}
onTypewriterComplete={handleTaskReadyTypewriterComplete}
/>
);
}
if (item.kind === 'roleplay-history') {
return (
<RoleplayHistoryBlock
key="roleplay-history"
messages={item.messages}
characters={scenarioCharacters}
open={roleplayHistoryOpen}
onToggle={() => 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 (
<div key={ev.id} className="flex justify-start">
<div className="pbl-v2-task-review-shell max-w-[90%] rounded-[22px] px-4 py-3 text-sm text-slate-800">
<div className="mb-1 text-[10px] uppercase tracking-wider text-violet-700">
{displayName} · {t('pbl.v2.taskEvalCard.title')}
</div>
{feedback && (
<MarkdownText
content={feedback}
className="pbl-v2-light-card-markdown pbl-v2-task-review-markdown text-slate-700 prose-p:text-slate-700 prose-strong:text-slate-900"
/>
)}
<TaskEvaluationCard evaluation={ev} className="mt-3" />
</div>
</div>
);
}
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 (
<MilestoneCard
key={ev.id}
evaluation={ev}
handover={handoverForMilestoneEvaluation(ev, project.pendingHandover)}
onContinue={handleContinueHandover}
className="my-1"
/>
);
}
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 (
<div key={ev.id} className="space-y-3">
<CompletionCtaCard
onView={() => onProjectChange(transitionProjectUiPhase(project, 'completed'))}
/>
</div>
);
}
return null;
})}
{submissionEvaluationStatus && (
<SubmissionEvaluationBubble
status={submissionEvaluationStatus}
agentName={displayName}
agentIntro={agentIntro}
/>
)}
{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.
<StreamingDraft
status={visibleStreamStatus}
// Evaluator output is JSON-only and lands as cards/report data.
// Keep the stream bubble as a neutral waiting state.
draft={visibleStreamStatus.startsWith('eval-') ? '' : visibleDraftAssistant}
agentName={displayName}
agentIntro={agentIntro}
handover={project.pendingHandover}
character={isRoleplay ? roleplayCharacter : undefined}
simPhase={simPhase}
/>
)}
{error && (
<div className="flex items-start justify-between gap-2 rounded-xl border border-destructive/40 bg-destructive/15 px-3 py-2 text-xs text-red-200">
<span>{error}</span>
<button
onClick={clearError}
className="shrink-0 underline underline-offset-2 hover:opacity-80"
>
{t('pbl.v2.hero.close')}
</button>
</div>
)}
<div ref={scrollEndRef} />
</main>
<footer
className="border-t border-cyan-100/[0.11] bg-[#101b32]/82 px-4 py-3 backdrop-blur"
onMouseMove={
handoverPending ? (e) => setHandoverHintPos({ x: e.clientX, y: e.clientY }) : undefined
}
onMouseLeave={handoverPending ? () => setHandoverHintPos(null) : undefined}
>
{/* Stage hand-off lives in the LEFT roadmap (sidebar), never here — and
only once the stage's tasks are all complete. Within a roleplay
stage the learner cannot click past an interaction; beats advance
only when the engine detects they actually happened. */}
{/* Roleplay act: a single light pointer to the right-side panel, which
already shows this act's full brief + all its (hidden-beat) hints,
bound to the current act. We intentionally DON'T duplicate per-beat
hints / suggested-reply buttons inline: in the act model beats don't
advance, so an inline beat-bound strip would be frozen on the first
beat. The right panel is the single, act-level source of guidance. */}
{isRoleplay && !handoverPending && !projectCompleted && (
<div className="mb-2 flex items-center gap-1.5 px-1 text-[11px] leading-snug text-muted-foreground/80">
<Lightbulb className="h-3.5 w-3.5 shrink-0 text-violet-200/70" />
<span>{t('pbl.v2.chat.guidancePointer')}</span>
</div>
)}
<div className="flex items-center gap-2 rounded-2xl border border-cyan-100/[0.13] bg-slate-700/[0.26] px-3 py-2 shadow-[0_12px_34px_rgba(6,16,34,0.28)] transition-all focus-within:border-primary/70 focus-within:bg-slate-700/[0.34] focus-within:shadow-[0_0_0_1px_rgba(157,140,255,0.28),0_16px_40px_rgba(6,16,34,0.30)]">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-cyan-100/[0.12] bg-cyan-100/[0.06] text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
</div>
<textarea
ref={textareaRef}
value={input}
rows={MIN_ROWS}
disabled={projectCompleted || handoverPending}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
projectCompleted
? t('pbl.v2.chat.placeholderCompleted')
: handoverPending
? t('pbl.v2.chat.handoverHint')
: t('pbl.v2.chat.placeholder', { name: displayName })
}
className={cn(
'flex-1 resize-none bg-transparent text-sm leading-6',
'text-foreground placeholder:text-muted-foreground focus:outline-none',
'pbl-v2-scrollbar max-h-[200px] overflow-y-auto whitespace-pre-wrap',
// While a handover is pending the textarea is disabled; drop
// pointer events so the footer still receives mousemove and
// can show the "click Continue" hint over the input area.
handoverPending && 'pointer-events-none',
)}
style={{ fontFamily: 'inherit' }}
/>
<SpeechButton
onTranscription={(text) => {
if (!text.trim()) return;
setInput((prev) => (prev ? `${prev} ${text}` : text));
}}
disabled={chatBusy || projectCompleted || handoverPending}
continuous
/>
<button
onClick={handleSend}
disabled={chatBusy || !input.trim() || projectCompleted || handoverPending}
aria-label={t('pbl.v2.chat.send')}
className={cn(
'shrink-0 rounded-xl h-9 w-9 flex items-center justify-center',
'bg-primary text-primary-foreground shadow-[0_0_24px_rgba(155,124,255,0.35)] hover:opacity-90 transition-opacity',
'disabled:opacity-40 disabled:cursor-not-allowed',
)}
>
<ArrowUp className="w-4 h-4" />
</button>
</div>
<p className="text-[10px] text-muted-foreground/80 text-center mt-2">
{t('pbl.v2.chat.keyboardHint')}
</p>
</footer>
{handoverPending && handoverHintPos && (
<div
className="pointer-events-none fixed z-50 max-w-[220px] rounded-md border border-violet-300/40 bg-slate-900/95 px-2.5 py-1.5 text-[11px] font-medium text-violet-100 shadow-[0_8px_24px_rgba(6,16,34,0.45)]"
style={{ left: handoverHintPos.x + 14, top: Math.max(8, handoverHintPos.y - 34) }}
>
{t('pbl.v2.chat.handoverHint')}
</div>
)}
</div>
);
}
function SubmissionEvaluationBubble({
status,
agentName,
agentIntro,
}: {
readonly status: SubmissionEvaluationStatus;
readonly agentName: string;
readonly agentIntro?: string;
}) {
const { t } = useI18n();
const streamStatus = status.streamStatus;
if (streamStatus && streamStatus !== 'idle') {
return (
<StreamingDraft
status={streamStatus}
draft={status.draft ?? ''}
agentName={agentName}
submissionMicrotaskTitle={status.microtaskTitle}
/>
);
}
const isFollowup = status.phase === 'followup';
if (isFollowup) {
return (
<div className="flex items-start gap-2">
<InstructorAvatar agentName={agentName} agentIntro={agentIntro} />
<div className="pbl-v2-instructor-bubble max-w-[90%] rounded-2xl px-4 py-3 text-sm">
<div className="pbl-v2-instructor-label mb-2 text-[10px] uppercase">
{t('pbl.v2.chat.thinking', { name: agentName })}
</div>
<ThinkingDots />
</div>
</div>
);
}
return (
<div className="flex justify-start">
<div className="max-w-[90%] rounded-xl border border-primary/30 bg-primary/[0.12] px-4 py-3 text-sm shadow-[0_12px_30px_rgba(6,16,34,0.22)]">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wider text-primary mb-2">
<CheckCircle2 className="w-3.5 h-3.5" />
{t('pbl.v2.chat.submitted')}
</div>
<div className="flex items-start gap-2 text-foreground">
<Loader2 className="w-4 h-4 mt-0.5 shrink-0 animate-spin text-primary" />
<div className="leading-relaxed">
<div className="font-medium">{t('pbl.v2.chat.evaluatingSubmission')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('pbl.v2.chat.evaluatingSubmissionDetail', { title: status.microtaskTitle })}
</div>
</div>
</div>
</div>
</div>
);
}
function StreamingDraft({
status,
draft,
agentName,
agentIntro,
handover,
submissionMicrotaskTitle,
character,
simPhase,
}: {
readonly status: import('./use-instructor-stream').StreamStatus;
readonly draft: string;
readonly agentName: string;
readonly agentIntro?: string;
readonly handover?: PBLProjectV2['pendingHandover'];
readonly submissionMicrotaskTitle?: string;
// SCENARIO ONLY: when the streaming turn is a roleplay character, show
// that character's avatar instead of the Instructor's.
readonly character?: PBLScenarioCharacter;
// SCENARIO ONLY: which Simulator sub-phase is loading (narration vs
// character) so the indicator is honest about who is "thinking".
readonly simPhase?: import('./use-instructor-stream').SimPhase;
}) {
const { t } = useI18n();
const displayDraft = usePacedText(draft, { active: status !== 'idle' });
const hasTokens = displayDraft.trim().length > 0;
const isCaughtUpToLatestChunk = hasTokens && displayDraft.length >= draft.length;
const label = streamStatusLabel(status, agentName, t, hasTokens);
// SCENARIO ONLY: the SYSTEM narrator is composing scene narration —
// show a neutral, centred "narrating…" indicator (no character avatar),
// matching the system-narration bubble style, so the learner knows it
// is the scene being set, not the character speaking. (Placed after the
// hook calls above so hook order stays unconditional.)
if (simPhase === 'narration') {
return (
<div className="flex justify-center py-1.5">
<div className="flex items-center gap-2 rounded-xl border border-cyan-100/[0.10] bg-slate-100/[0.05] px-4 py-2 text-[13px] italic text-muted-foreground">
<span>{t('pbl.v2.chat.narrating')}</span>
<ThinkingDots />
</div>
</div>
);
}
if (status === 'eval-task') {
return (
<div className="flex justify-start">
<div className="pbl-v2-task-review-shell max-w-[90%] animate-in fade-in-0 rounded-[22px] px-4 py-3 text-sm text-slate-800 duration-300">
<div className="mb-1 text-[10px] uppercase tracking-wider text-violet-700">{label}</div>
{hasTokens ? (
<MarkdownText
content={streamingEvaluationPreview(displayDraft)}
className="pbl-v2-light-card-markdown pbl-v2-task-review-markdown text-slate-700 prose-p:text-slate-700 prose-strong:text-slate-900"
/>
) : (
<div className="flex items-start gap-2 text-slate-600">
<ThinkingDots tone="violet" />
<div className="pt-0.5 text-xs leading-relaxed text-slate-500">
{submissionMicrotaskTitle
? t('pbl.v2.chat.readingSubmissionWithTitle', { title: submissionMicrotaskTitle })
: t('pbl.v2.chat.readingSubmission')}
</div>
</div>
)}
{isCaughtUpToLatestChunk && <ContinuingDots tone="violet" />}
</div>
</div>
);
}
if (status === 'eval-milestone') {
return (
<div className="relative animate-in fade-in-0 overflow-hidden rounded-2xl border border-violet-200/85 bg-[linear-gradient(145deg,rgba(252,250,255,0.98)_0%,rgba(238,242,255,0.94)_48%,rgba(232,250,255,0.96)_100%)] p-5 text-slate-800 shadow-[inset_0_1px_0_rgba(255,255,255,0.92),0_22px_58px_rgba(8,18,38,0.30),0_0_0_1px_rgba(139,92,246,0.10),0_0_42px_rgba(34,211,238,0.10)] ring-1 ring-violet-300/20 duration-300">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-violet-500 via-indigo-500 to-cyan-400" />
<div className="relative mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-violet-700">
{label}
</div>
{hasTokens ? (
<MarkdownText
content={streamingEvaluationPreview(displayDraft)}
className="pbl-v2-light-card-markdown text-slate-700 prose-p:text-slate-700 prose-strong:text-slate-900 prose-li:marker:text-violet-500"
/>
) : (
<ThinkingDots tone="violet" />
)}
{handover && (
<div className="relative mt-3 border-t border-violet-200/80 pt-2 text-xs text-slate-500">
{t('pbl.v2.chat.milestoneFeedbackPending')}
</div>
)}
{isCaughtUpToLatestChunk && <ContinuingDots tone="violet" />}
</div>
);
}
if (status === 'eval-final') {
return (
<div className="space-y-3">
<div className="animate-in fade-in-0 rounded-xl border border-cyan-100/[0.12] bg-slate-800/[0.48] px-4 py-3 text-sm shadow-[0_12px_30px_rgba(6,16,34,0.22)] duration-300">
<div className="mb-1 text-[10px] uppercase text-muted-foreground">{label}</div>
{hasTokens ? (
<MarkdownText content={streamingEvaluationPreview(displayDraft)} />
) : (
<ThinkingDots />
)}
{isCaughtUpToLatestChunk && <ContinuingDots />}
</div>
</div>
);
}
const displayContent = streamingMessagePreview(displayDraft);
return (
<div className="flex items-start gap-2">
{character ? (
<CharacterAvatar character={character} name={agentName} />
) : (
<InstructorAvatar agentName={agentName} agentIntro={agentIntro} />
)}
<div className="pbl-v2-instructor-bubble max-w-[90%] rounded-2xl px-4 py-3 text-sm">
<div className="pbl-v2-instructor-label mb-2 text-[10px] uppercase">{label}</div>
{displayContent.trim() ? <MarkdownText content={displayContent} /> : <ThinkingDots />}
{isCaughtUpToLatestChunk && <ContinuingDots />}
</div>
</div>
);
}
/**
* SCENARIO ONLY. Collapsible block that embeds the whole roleplay-scene
* conversation inside the Instructor timeline (between prep and wrapup), so the
* three stages read as one continuous history. Default collapsed; the toggle is
* prominent and clearly labelled. Reuses MessageBubble so the folded content
* renders identically to the live scene.
*/
function RoleplayHistoryBlock({
messages,
characters,
open,
onToggle,
title,
}: {
readonly messages: readonly PBLChatMessage[];
readonly characters?: readonly PBLScenarioCharacter[];
readonly open: boolean;
readonly onToggle: () => void;
readonly title: string;
}) {
return (
<div className="rounded-2xl border border-violet-300/25 bg-violet-500/[0.06]">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2 rounded-2xl px-4 py-3 text-left transition-colors hover:bg-violet-500/[0.10]"
>
<Drama className="h-4 w-4 shrink-0 text-violet-200/90" />
<span className="flex-1 text-sm font-semibold text-violet-50">{title}</span>
<span className="text-[11px] text-violet-200/70">{messages.length}</span>
{open ? (
<ChevronDown className="h-4 w-4 shrink-0 text-violet-200/80" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-violet-200/80" />
)}
</button>
{open && (
<div className="space-y-3 border-t border-violet-300/15 px-4 py-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} agentName="" characters={characters} />
))}
</div>
)}
</div>
);
}
function MessageBubble({
message,
agentName,
characters,
agentIntro,
typewriter = false,
onTypewriterComplete,
}: {
readonly message: PBLChatMessage;
readonly agentName: string;
readonly characters?: readonly PBLScenarioCharacter[];
readonly agentIntro?: string;
readonly typewriter?: boolean;
readonly onTypewriterComplete?: (messageId: string) => void;
}) {
// SCENARIO ONLY — neutral system narration (旁白): centred, italic, no
// avatar/name, so it reads as the scene speaking, not a person.
if (message.roleType === 'system') {
const narration = trimmedPBLText(message.content);
if (!narration) return null;
return (
<div className="flex justify-center py-1.5">
<div className="max-w-[82%] rounded-xl border border-cyan-100/[0.10] bg-slate-100/[0.05] px-4 py-2 text-center text-[13px] italic leading-relaxed text-muted-foreground">
{narration}
</div>
</div>
);
}
// SCENARIO ONLY — an in-character line from the cast. Distinct avatar +
// the character's own name so it never reads like the Instructor.
if (message.roleType === 'simulator') {
const character = characters?.find((c) => c.id === message.characterId) ?? characters?.[0];
const name = character?.name ?? agentName;
const content = trimmedPBLText(message.content);
if (!content) return null;
return (
<div className="flex justify-start">
<CharacterAvatar character={character} name={name} />
<div className="ml-2 max-w-[85%] rounded-2xl border border-amber-200/20 bg-amber-100/[0.06] px-4 py-3 text-sm text-card-foreground shadow-[0_12px_30px_rgba(6,16,34,0.22)]">
<div className="mb-1 text-[10px] uppercase tracking-wider text-amber-200/80">{name}</div>
<MarkdownText content={content} />
</div>
</div>
);
}
if (
message.roleType === 'instructor' &&
typeof message.content === 'string' &&
message.content.startsWith(TASK_DIVIDER_PREFIX)
) {
const label = message.content.slice(TASK_DIVIDER_PREFIX.length).trim();
return (
<div className="flex justify-center py-1">
<div className="rounded-full border border-slate-200/70 bg-slate-100/90 px-3 py-1 text-[11px] font-medium text-slate-600 shadow-[0_10px_24px_rgba(6,16,34,0.18)]">
{label}
</div>
</div>
);
}
if (
message.roleType === 'instructor' &&
typeof message.content === 'string' &&
message.content.startsWith(MILESTONE_DIVIDER_PREFIX)
) {
const label = message.content.slice(MILESTONE_DIVIDER_PREFIX.length).trim();
return (
<div className="flex items-center justify-center py-3" aria-label={label}>
<div className="flex min-w-[52%] max-w-[90%] items-center gap-3">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-violet-200/70 to-cyan-200/70" />
<div className="relative rounded-full border border-violet-200/70 bg-gradient-to-r from-violet-50/95 via-indigo-50/90 to-cyan-50/95 px-4 py-1.5 text-[11px] font-semibold text-violet-700 shadow-[0_12px_34px_rgba(55,48,163,0.24)]">
<span className="absolute -left-1 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-violet-400 shadow-[0_0_10px_rgba(167,139,250,0.48)]" />
<span className="absolute -right-1 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.45)]" />
{label}
</div>
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-cyan-200/70 to-violet-200/70" />
</div>
</div>
);
}
const isUser = message.roleType === 'user';
const displayContent = trimmedPBLText(
!isUser && typeof message.content === 'string'
? stripEmbeddedDividerMarkers(message.content)
: message.content,
);
if (!displayContent) return null;
if (!isUser) {
return (
<InstructorMessageBubble
key={`${message.id}:${typewriter ? 'typewriter' : 'static'}`}
messageId={message.id}
content={displayContent}
agentName={agentName}
agentIntro={agentIntro}
typewriter={typewriter}
onTypewriterComplete={onTypewriterComplete}
/>
);
}
return (
<div className="flex justify-end">
<div
className={cn(
'max-w-[85%] rounded-2xl px-4 py-3 text-sm shadow-[0_12px_30px_rgba(6,16,34,0.22)]',
'bg-gradient-to-br from-primary to-violet-400 text-primary-foreground shadow-[0_14px_34px_rgba(124,92,255,0.24)]',
)}
>
<div className="whitespace-pre-wrap break-words">{displayContent}</div>
</div>
</div>
);
}
function InstructorMessageBubble({
messageId,
content,
agentName,
agentIntro,
typewriter,
onTypewriterComplete,
}: {
readonly messageId: string;
readonly content: string;
readonly agentName: string;
readonly agentIntro?: string;
readonly typewriter: boolean;
readonly onTypewriterComplete?: (messageId: string) => void;
}) {
const displayContent = usePacedText(content, { active: typewriter });
const typing = typewriter && displayContent.length < content.length;
useEffect(() => {
if (!typewriter || typing) return;
onTypewriterComplete?.(messageId);
}, [typewriter, typing, messageId, onTypewriterComplete]);
return (
<div className="flex justify-start">
<InstructorAvatar agentName={agentName} agentIntro={agentIntro} />
<div className="pbl-v2-instructor-bubble ml-2 max-w-[85%] rounded-2xl px-4 py-3 text-sm text-card-foreground shadow-[0_12px_30px_rgba(6,16,34,0.22)]">
<div className="pbl-v2-instructor-label mb-1 text-[10px] uppercase">{agentName}</div>
<MarkdownText content={displayContent} />
{typing && <ContinuingDots />}
</div>
</div>
);
}
/** PBL v2 instructor avatar — a PBL-owned asset, intentionally NOT
* roundtable's `DEFAULT_TEACHER_AVATAR`, so the instructor's face is
* decoupled from the OpenMAIC classroom teacher avatar; changing one
* never affects the other. */
const PBL_INSTRUCTOR_AVATAR = '/avatars/instructor.png';
const INSTRUCTOR_AVATAR_RING =
'h-8 w-8 overflow-hidden rounded-full border border-primary/35 bg-primary/15 shadow-[0_0_22px_rgba(169,148,255,0.22)]';
function InstructorAvatar({
agentName,
agentIntro,
}: {
readonly agentName: string;
readonly agentIntro?: string;
}) {
// No curated intro (un-set role): keep the original lightweight behaviour —
// a native title with the name. The avatar stays decorative.
if (!agentIntro) {
return (
<div
className={cn('mt-0.5 shrink-0', INSTRUCTOR_AVATAR_RING)}
title={agentName}
aria-hidden="true"
>
<AvatarDisplay src={PBL_INSTRUCTOR_AVATAR} alt={agentName} />
</div>
);
}
// With an intro: a pure-CSS hover tooltip. group-hover is instant (no JS
// delay, no portal that could fail to render), the cursor is left unchanged,
// and the frame is a clean dark card (no border) with roomy padding.
return (
// `self-start` so the wrapper is exactly the avatar's height (it must NOT
// stretch to the message-row height — otherwise `top-full` lands far below
// the bubble). The tooltip then sits right under the avatar, near the
// pointer; overlapping the bubble is fine.
<div className="group relative mt-0.5 shrink-0 self-start">
<div className={INSTRUCTOR_AVATAR_RING} aria-hidden="true">
<AvatarDisplay src={PBL_INSTRUCTOR_AVATAR} alt={agentName} />
</div>
<div
role="tooltip"
className="pointer-events-none absolute left-0 top-full z-50 mt-2 w-max max-w-xs whitespace-normal rounded-lg bg-neutral-900 px-3.5 py-2.5 text-xs leading-relaxed text-neutral-100 opacity-0 shadow-xl transition-opacity duration-100 group-hover:opacity-100"
>
{agentIntro}
</div>
</div>
);
}
/** SCENARIO ONLY — avatar for an in-scene character. Uses the authored
* `character.avatar` when present; otherwise a warm initial-letter
* badge, visually distinct from the Instructor avatar so the learner
* always knows who is speaking. */
function CharacterAvatar({
character,
name,
}: {
readonly character?: PBLScenarioCharacter;
readonly name: string;
}) {
if (character?.avatar) {
return (
<div
className="mt-0.5 h-8 w-8 shrink-0 overflow-hidden rounded-full border border-amber-200/40 bg-amber-100/15 shadow-[0_0_22px_rgba(251,191,36,0.18)]"
title={name}
aria-hidden="true"
>
<AvatarDisplay src={character.avatar} alt={name} />
</div>
);
}
return (
<div
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-amber-200/40 bg-gradient-to-br from-amber-300/30 to-rose-300/25 text-xs font-semibold text-amber-100 shadow-[0_0_22px_rgba(251,191,36,0.18)]"
title={name}
aria-hidden="true"
>
{Array.from(trimmedPBLText(name))[0] ?? '·'}
</div>
);
}
function usePacedText(raw: string, opts: { readonly active: boolean }): string {
const [visible, setVisible] = useState(() => (opts.active ? '' : raw));
const visibleRef = useRef(visible);
const rawRef = useRef(raw);
useEffect(() => {
visibleRef.current = visible;
}, [visible]);
useEffect(() => {
rawRef.current = raw;
if (!opts.active) {
visibleRef.current = raw;
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional derived-state sync for paced text streaming
setVisible(raw);
return;
}
if (!raw) {
visibleRef.current = '';
setVisible('');
return;
}
if (visibleRef.current === raw) {
visibleRef.current = '';
setVisible('');
return;
}
if (!raw.startsWith(visibleRef.current)) {
visibleRef.current = raw;
setVisible(raw);
}
}, [raw, opts.active]);
useEffect(() => {
if (!opts.active) return;
let cancelled = false;
let timer: number | undefined;
const tick = () => {
if (cancelled) return;
const target = rawRef.current;
const current = visibleRef.current;
if (current.length >= target.length) {
timer = window.setTimeout(tick, 24);
return;
}
const remaining = target.length - current.length;
const step = pacedStep(target.slice(current.length), remaining);
const next = target.slice(0, current.length + step);
visibleRef.current = next;
setVisible(next);
timer = window.setTimeout(tick, pacedDelay(remaining));
};
timer = window.setTimeout(tick, 18);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [opts.active]);
return visible;
}
function pacedStep(upcoming: string, remaining: number): number {
if (remaining <= 0) return 0;
const first = Array.from(upcoming).slice(0, 12);
if (first.length === 0) return 1;
// Keep punctuation attached to the preceding phrase so the prose
// feels typed, not chopped into mechanical single glyphs.
const punctuationAt = first.findIndex((ch) => /[,。!?;:,.!?;:、\n]/u.test(ch));
if (punctuationAt >= 0 && punctuationAt <= 5) return punctuationAt + 1;
if (remaining > 240) return 3;
if (remaining > 80) return 2;
return 1;
}
function pacedDelay(remaining: number): number {
if (remaining > 240) return 14;
if (remaining > 120) return 18;
if (remaining > 40) return 24;
return 32;
}
function EmptyChatPlaceholder({ agentName }: { readonly agentName: string }) {
const { t } = useI18n();
return (
<div className="py-10 text-center text-sm text-muted-foreground">
{t('pbl.v2.chat.emptyChat')}
<br />
{t('pbl.v2.chat.emptyChatHint', { name: agentName })}
</div>
);
}
// ---------------------------------------------------------------------------
// Timeline merge: messages + evaluations → one stream in time order
// ---------------------------------------------------------------------------
export type TimelineItem =
| { kind: 'message'; ts: string; message: PBLChatMessage }
| { kind: 'evaluation'; ts: string; evaluation: PBLEvaluation }
| { kind: 'roleplay-history'; ts: string; messages: PBLChatMessage[] };
/**
* Merge messages and evaluations into a single ordered feed.
*
* Evaluations are sorted alongside chat by their `createdAt`. Ties
* resolve eval-after-message — if a message and an eval share the
* exact same timestamp (rare; happens when the SSE patch fires
* within the same millisecond as a streamed token), the message
* appears first so the assistant's prose is read before the
* structured card it produced.
*
* SCENARIO ONLY: when `roleplayHistory` is non-empty, the whole
* roleplay conversation is folded into ONE collapsible item, slotted by
* the timestamp of its first message — so it lands between the prep
* messages (earlier) and the wrapup messages (later), reconnecting the
* three stages into one continuous Instructor-side feed.
*
* Pure function — easy to unit-test independently of React.
*/
export function buildTimeline(
messages: PBLChatMessage[],
evaluations: PBLEvaluation[],
roleplayHistory: PBLChatMessage[] = [],
): TimelineItem[] {
const items: TimelineItem[] = [
...messages.map((m): TimelineItem => ({ kind: 'message', ts: m.ts, message: m })),
...evaluations.map(
(e): TimelineItem => ({ kind: 'evaluation', ts: e.createdAt, evaluation: e }),
),
];
if (roleplayHistory.length > 0) {
items.push({
kind: 'roleplay-history',
ts: roleplayHistory[0].ts,
messages: roleplayHistory,
});
}
items.sort((a, b) => {
if (a.ts !== b.ts) return a.ts.localeCompare(b.ts);
// tie-break: message before evaluation
if (a.kind === b.kind) return 0;
return a.kind === 'message' ? -1 : 1;
});
return items;
}
function stripTailForDisplay(text: unknown): string {
return stripEvaluationTail(trimmedPBLText(text)).trim();
}
export function displayAgentName(name: unknown, fallback: string): string {
const trimmed = trimmedPBLText(name);
return trimmed || fallback;
}
export function shouldShowStreamingDraft({
streaming,
instructorStreaming,
draftAssistant,
streamCommittedOutput,
hasExternalDraft = false,
submissionEvaluationActive,
}: {
readonly streaming: boolean;
readonly instructorStreaming: boolean;
readonly draftAssistant: string;
readonly streamCommittedOutput: boolean;
readonly hasExternalDraft?: boolean;
readonly submissionEvaluationActive: boolean;
}): boolean {
if (submissionEvaluationActive) return false;
if (streaming || hasExternalDraft) {
return !streamCommittedOutput || draftAssistant.trim().length > 0;
}
if (streamCommittedOutput) return false;
return instructorStreaming;
}
export function handoverForMilestoneEvaluation(
evaluation: PBLEvaluation,
handover: PBLProjectV2['pendingHandover'],
): PBLProjectV2['pendingHandover'] | undefined {
if (evaluation.kind !== 'milestone') return undefined;
if (!handover) return undefined;
if (!evaluation.milestoneId) return undefined;
return handover.completedMilestoneId === evaluation.milestoneId ? handover : undefined;
}
export function streamingEvaluationPreview(text: string): string {
const withoutFence = text.replace(/```j(?:s(?:o(?:n)?)?)?[\s\S]*$/i, '').trimEnd();
const withoutBareJson = withoutFence.replace(/\n\s*\{[\s\S]*$/m, '').trimEnd();
return withoutBareJson;
}
export function streamingMessagePreview(text: string): string {
return stripLeakedToolJsonPreview(stripEmbeddedDividerMarkers(text)).trimEnd();
}
export function stripLeakedToolJsonPreview(text: string): string {
const withoutCompleteJson = text.replace(
/[^\S\r\n]*(?:\{[\s\S]{0,500}?"kind"\s*:\s*"(?:concept_unlocked|error|struggle|question)"[\s\S]{0,500}?\}|\{[\s\S]{0,500}?"signature"\s*:\s*"[^"]+"[\s\S]{0,500}?\})[^\S\r\n]*/g,
'',
);
return withoutCompleteJson
.replace(
/[^\S\r\n]*\{(?=[\s\S]{0,500}?"kind"\s*:\s*"(?:concept_unlocked|error|struggle|question)")(?![\s\S]*\})[\s\S]*$/g,
'',
)
.replace(/[^\S\r\n]*\{(?=[\s\S]{0,500}?"signature"\s*:)(?![\s\S]*\})[\s\S]*$/g, '')
.replace(/[^\S\r\n]*\{[\s\S]{0,500}?"(?:kind|signature)"\s*:\s*"[^"]*$/g, '')
.trim();
}
/**
* Map the stream's coarse-grained phase to the small "X · 思考中" /
* "X · 输入中…" line that floats above the streaming bubble. Per
* PR 6 D3-B: keep evaluator labels neutral (no "等我整理一下" filler).
*/
function streamStatusLabel(
status: import('./use-instructor-stream').StreamStatus,
agentName: string,
t: (key: string, options?: Record<string, unknown>) => string,
hasTokens = false,
): string {
switch (status) {
case 'eval-task':
return t('pbl.v2.chat.evaluatingTask', { name: agentName });
case 'eval-milestone':
return t('pbl.v2.chat.generatingMilestoneFeedback');
case 'eval-final':
return t('pbl.v2.chat.generatingFinalEval');
case 'instructor':
case 'idle':
default:
return hasTokens
? t('pbl.v2.chat.typing', { name: agentName })
: t('pbl.v2.chat.thinking', { name: agentName });
}
}
/** Three-dot typing indicator. Used while we know the LLM is
* processing but haven't received any tokens yet.
*
* Uses a custom opacity animation (1 → 0.15 → 1) instead of
* `animate-bounce` to avoid vertical overlap with the label above.
* The swing is intentionally wide so the wave is clearly visible
* even on small dots against dark backgrounds. */
function ThinkingDots({ tone = 'muted' }: { readonly tone?: 'muted' | 'violet' } = {}) {
const dotClass =
tone === 'violet'
? 'bg-violet-500/70 shadow-[0_0_10px_rgba(139,92,246,0.28)]'
: 'bg-muted-foreground';
return (
<div className="flex items-center gap-1 py-1">
<span
className={cn('w-1.5 h-1.5 rounded-full', dotClass)}
style={{
animationName: 'think-dot-pulse',
animationDuration: '1.4s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: '-0.3s',
}}
/>
<span
className={cn('w-1.5 h-1.5 rounded-full', dotClass)}
style={{
animationName: 'think-dot-pulse',
animationDuration: '1.4s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: '-0.15s',
}}
/>
<span
className={cn('w-1.5 h-1.5 rounded-full', dotClass)}
style={{
animationName: 'think-dot-pulse',
animationDuration: '1.4s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: '0s',
}}
/>
</div>
);
}
function ContinuingDots({ tone = 'muted' }: { readonly tone?: 'muted' | 'violet' } = {}) {
const { t } = useI18n();
const dotClass =
tone === 'violet'
? 'bg-violet-500/65 shadow-[0_0_10px_rgba(139,92,246,0.24)]'
: 'bg-muted-foreground/55';
return (
<div className="mt-1 flex items-center gap-1" aria-label={t('pbl.v2.chat.stillTyping')}>
<span
className={cn('h-1 w-1 rounded-full', dotClass)}
style={{
animationName: 'think-dot-pulse',
animationDuration: '1.4s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: '-0.2s',
}}
/>
<span
className={cn('h-1 w-1 rounded-full', dotClass)}
style={{
animationName: 'think-dot-pulse',
animationDuration: '1.4s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: '-0.1s',
}}
/>
<span
className={cn('h-1 w-1 rounded-full', dotClass)}
style={{
animationName: 'think-dot-pulse',
animationDuration: '1.4s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: '0s',
}}
/>
</div>
);
}