import ReactMarkdown from 'react-markdown'; import './MessageContent.css'; // Serve any temp visualization image (GradCAM, comparison) through the API const TEMP_IMG_URL = (path: string) => `/api/patients/gradcam?path=${encodeURIComponent(path)}`; // ─── Types ───────────────────────────────────────────────────────────────── type Segment = | { type: 'stage'; label: string } | { type: 'thinking'; content: string } | { type: 'response'; content: string } | { type: 'tool_output'; label: string; content: string } | { type: 'gradcam'; path: string } | { type: 'comparison'; path: string } | { type: 'gradcam_compare'; path1: string; path2: string } | { type: 'result'; content: string } | { type: 'error'; content: string } | { type: 'complete'; content: string } | { type: 'references'; content: string } | { type: 'observation'; content: string } | { type: 'text'; content: string }; // ─── Parser ──────────────────────────────────────────────────────────────── // Splits raw text by all known complete tag patterns (capturing group preserves them) const TAG_SPLIT_RE = new RegExp( '(' + [ '\\[STAGE:[^\\]]*\\][\\s\\S]*?\\[\\/STAGE\\]', '\\[THINKING\\][\\s\\S]*?\\[\\/THINKING\\]', '\\[RESPONSE\\][\\s\\S]*?\\[\\/RESPONSE\\]', '\\[TOOL_OUTPUT:[^\\]]*\\][\\s\\S]*?\\[\\/TOOL_OUTPUT\\]', '\\[GRADCAM_IMAGE:[^\\]]+\\]', '\\[COMPARISON_IMAGE:[^\\]]+\\]', '\\[GRADCAM_COMPARE:[^:\\]]+:[^\\]]+\\]', '\\[RESULT\\][\\s\\S]*?\\[\\/RESULT\\]', '\\[ERROR\\][\\s\\S]*?\\[\\/ERROR\\]', '\\[COMPLETE\\][\\s\\S]*?\\[\\/COMPLETE\\]', '\\[REFERENCES\\][\\s\\S]*?\\[\\/REFERENCES\\]', '\\[OBSERVATION\\][\\s\\S]*?\\[\\/OBSERVATION\\]', '\\[CONFIRM:[^\\]]*\\][\\s\\S]*?\\[\\/CONFIRM\\]', ].join('|') + ')', 'g', ); // Strips known opening tags that haven't yet been closed (mid-stream partial content) function cleanStreamingText(text: string): string { return text.replace( /\[(STAGE:[^\]]*|THINKING|RESPONSE|TOOL_OUTPUT:[^\]]*|RESULT|ERROR|COMPLETE|REFERENCES|OBSERVATION|CONFIRM:[^\]]*)\]/g, '', ); } function parseContent(raw: string): Segment[] { const segments: Segment[] = []; for (const part of raw.split(TAG_SPLIT_RE)) { if (!part) continue; let m: RegExpMatchArray | null; if ((m = part.match(/^\[STAGE:([^\]]*)\]([\s\S]*)\[\/STAGE\]$/))) { const label = m[2].trim(); if (label) segments.push({ type: 'stage', label }); } else if ((m = part.match(/^\[THINKING\]([\s\S]*)\[\/THINKING\]$/))) { const c = m[1].trim(); if (c) segments.push({ type: 'thinking', content: c }); } else if ((m = part.match(/^\[RESPONSE\]([\s\S]*)\[\/RESPONSE\]$/))) { const c = m[1].trim(); if (c) segments.push({ type: 'response', content: c }); } else if ((m = part.match(/^\[TOOL_OUTPUT:([^\]]*)\]([\s\S]*)\[\/TOOL_OUTPUT\]$/))) { segments.push({ type: 'tool_output', label: m[1], content: m[2] }); } else if ((m = part.match(/^\[GRADCAM_IMAGE:([^\]]+)\]$/))) { segments.push({ type: 'gradcam', path: m[1] }); } else if ((m = part.match(/^\[COMPARISON_IMAGE:([^\]]+)\]$/))) { segments.push({ type: 'comparison', path: m[1] }); } else if ((m = part.match(/^\[GRADCAM_COMPARE:([^:\]]+):([^\]]+)\]$/))) { segments.push({ type: 'gradcam_compare', path1: m[1], path2: m[2] }); } else if ((m = part.match(/^\[RESULT\]([\s\S]*)\[\/RESULT\]$/))) { const c = m[1].trim(); if (c) segments.push({ type: 'result', content: c }); } else if ((m = part.match(/^\[ERROR\]([\s\S]*)\[\/ERROR\]$/))) { const c = m[1].trim(); if (c) segments.push({ type: 'error', content: c }); } else if ((m = part.match(/^\[COMPLETE\]([\s\S]*)\[\/COMPLETE\]$/))) { const c = m[1].trim(); if (c) segments.push({ type: 'complete', content: c }); } else if ((m = part.match(/^\[REFERENCES\]([\s\S]*)\[\/REFERENCES\]$/))) { segments.push({ type: 'references', content: m[1].trim() }); } else if ((m = part.match(/^\[OBSERVATION\]([\s\S]*)\[\/OBSERVATION\]$/))) { const c = m[1].trim(); if (c) segments.push({ type: 'observation', content: c }); } else if ((m = part.match(/^\[CONFIRM:[^\]]*\]([\s\S]*)\[\/CONFIRM\]$/))) { const c = m[1].trim(); if (c) segments.push({ type: 'result', content: c }); } else { // Plain text (may be mid-stream with incomplete opening tags) const cleaned = cleanStreamingText(part); if (cleaned.trim()) segments.push({ type: 'text', content: cleaned }); } } return segments; } // ─── References renderer ─────────────────────────────────────────────────── function References({ content }: { content: string }) { const refs = content.match(/\[REF:[^\]]+\]/g) ?? []; if (!refs.length) return null; return (
References
{refs.map((ref, i) => { // [REF:id:source:page:file:superscript] const parts = ref.slice(1, -1).split(':'); const source = parts[2] ?? ''; const page = parts[3] ?? ''; const sup = parts[5] ?? `[${i + 1}]`; return (
{sup} {source} {page && , p.{page}}
); })}
); } // ─── Main component ──────────────────────────────────────────────────────── export function MessageContent({ text }: { text: string }) { const segments = parseContent(text); return (
{segments.map((seg, i) => { switch (seg.type) { case 'stage': return
{seg.label}
; case 'thinking': { // Spinner only on the last thinking segment (earlier ones are done) const isLast = !segments.slice(i + 1).some(s => s.type !== 'text' || s.content.trim()); return (
{isLast ? : } {seg.content}
); } case 'response': return (
{seg.content}
); case 'tool_output': return (
{seg.label &&
{seg.label}
}
{seg.content}
); case 'gradcam': return (
Grad-CAM Attention Map
Grad-CAM attention map
); case 'comparison': return (
Lesion Comparison
Side-by-side lesion comparison
); case 'gradcam_compare': return (
Grad-CAM Comparison
Previous
Previous GradCAM
Current
Current GradCAM
); case 'result': return
{seg.content}
; case 'error': return
{seg.content}
; case 'complete': return
{seg.content}
; case 'references': return ; case 'observation': return
{seg.content}
; case 'text': return seg.content.trim() ? (
{seg.content}
) : null; default: return null; } })}
); }