| | import ReactMarkdown from 'react-markdown'; |
| | import './MessageContent.css'; |
| |
|
| | |
| | const TEMP_IMG_URL = (path: string) => |
| | `/api/patients/gradcam?path=${encodeURIComponent(path)}`; |
| |
|
| | |
| |
|
| | 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 }; |
| |
|
| | |
| |
|
| | |
| | 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', |
| | ); |
| |
|
| | |
| | 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 { |
| | |
| | const cleaned = cleanStreamingText(part); |
| | if (cleaned.trim()) segments.push({ type: 'text', content: cleaned }); |
| | } |
| | } |
| |
|
| | return segments; |
| | } |
| |
|
| | |
| |
|
| | function References({ content }: { content: string }) { |
| | const refs = content.match(/\[REF:[^\]]+\]/g) ?? []; |
| | if (!refs.length) return null; |
| |
|
| | return ( |
| | <div className="mc-references"> |
| | <div className="mc-references-title">References</div> |
| | {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 ( |
| | <div key={i} className="mc-ref-item"> |
| | <span className="mc-ref-sup">{sup}</span> |
| | <span className="mc-ref-source">{source}</span> |
| | {page && <span className="mc-ref-page">, p.{page}</span>} |
| | </div> |
| | ); |
| | })} |
| | </div> |
| | ); |
| | } |
| |
|
| | |
| |
|
| | export function MessageContent({ text }: { text: string }) { |
| | const segments = parseContent(text); |
| |
|
| | return ( |
| | <div className="mc-root"> |
| | {segments.map((seg, i) => { |
| | switch (seg.type) { |
| | case 'stage': |
| | return <div key={i} className="mc-stage">{seg.label}</div>; |
| | |
| | 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 ( |
| | <div key={i} className="mc-thinking"> |
| | {isLast |
| | ? <span className="mc-thinking-spinner" /> |
| | : <span className="mc-thinking-done" />} |
| | {seg.content} |
| | </div> |
| | ); |
| | } |
| | |
| | case 'response': |
| | return ( |
| | <div key={i} className="mc-response"> |
| | <ReactMarkdown>{seg.content}</ReactMarkdown> |
| | </div> |
| | ); |
| | |
| | case 'tool_output': |
| | return ( |
| | <div key={i} className="mc-tool-output"> |
| | {seg.label && <div className="mc-tool-output-label">{seg.label}</div>} |
| | <pre>{seg.content}</pre> |
| | </div> |
| | ); |
| | |
| | case 'gradcam': |
| | return ( |
| | <div key={i} className="mc-image-block"> |
| | <div className="mc-image-label">Grad-CAM Attention Map</div> |
| | <img |
| | src={TEMP_IMG_URL(seg.path)} |
| | className="mc-gradcam-img" |
| | alt="Grad-CAM attention map" |
| | /> |
| | </div> |
| | ); |
| | |
| | case 'comparison': |
| | return ( |
| | <div key={i} className="mc-image-block"> |
| | <div className="mc-image-label">Lesion Comparison</div> |
| | <img |
| | src={TEMP_IMG_URL(seg.path)} |
| | className="mc-comparison-img" |
| | alt="Side-by-side lesion comparison" |
| | /> |
| | </div> |
| | ); |
| | |
| | case 'gradcam_compare': |
| | return ( |
| | <div key={i} className="mc-image-block"> |
| | <div className="mc-image-label">Grad-CAM Comparison</div> |
| | <div className="mc-gradcam-compare"> |
| | <div className="mc-gradcam-compare-item"> |
| | <div className="mc-gradcam-compare-title">Previous</div> |
| | <img |
| | src={TEMP_IMG_URL(seg.path1)} |
| | className="mc-gradcam-compare-img" |
| | alt="Previous GradCAM" |
| | /> |
| | </div> |
| | <div className="mc-gradcam-compare-item"> |
| | <div className="mc-gradcam-compare-title">Current</div> |
| | <img |
| | src={TEMP_IMG_URL(seg.path2)} |
| | className="mc-gradcam-compare-img" |
| | alt="Current GradCAM" |
| | /> |
| | </div> |
| | </div> |
| | </div> |
| | ); |
| | |
| | case 'result': |
| | return <div key={i} className="mc-result">{seg.content}</div>; |
| | |
| | case 'error': |
| | return <div key={i} className="mc-error">{seg.content}</div>; |
| | |
| | case 'complete': |
| | return <div key={i} className="mc-complete">{seg.content}</div>; |
| | |
| | case 'references': |
| | return <References key={i} content={seg.content} />; |
| | |
| | case 'observation': |
| | return <div key={i} className="mc-observation">{seg.content}</div>; |
| | |
| | case 'text': |
| | return seg.content.trim() ? ( |
| | <div key={i} className="mc-text">{seg.content}</div> |
| | ) : null; |
| | |
| | default: |
| | return null; |
| | } |
| | })} |
| | </div> |
| | ); |
| | } |
| |
|