SkinProAI / web /src /components /MessageContent.tsx
cgoodmaker's picture
Initial commit β€” SkinProAI dermoscopic analysis platform
86f402d
raw
history blame
9.53 kB
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 (
<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>
);
}
// ─── Main component ────────────────────────────────────────────────────────
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':
return <div key={i} className="mc-thinking">{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>
);
}