inspector/clients/web/src/components/elements/MessageBubble/MessageBubble.tsx
cliffhall 17f489fb64 Use monospace font for MessageBubble role label
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 18:15:47 -04:00

110 lines
2.7 KiB
TypeScript

import { Group, Image, Paper, Stack, Text } from "@mantine/core";
import type {
PromptMessage,
SamplingMessage,
} from "@modelcontextprotocol/sdk/types.js";
import { CopyButton } from "../CopyButton/CopyButton";
export interface MessageBubbleProps {
index: number;
message: SamplingMessage | PromptMessage;
}
function buildDataUri(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}
function formatRoleLabel(index: number, role: string): string {
return `[${index}] role: ${role}`;
}
function formatQuotedContent(content: string): string {
return `"${content}"`;
}
interface ContentBlockRendered {
text: string;
imageUri?: string;
audioUri?: string;
audioMime?: string;
}
function extractContent(
message: SamplingMessage | PromptMessage,
): ContentBlockRendered {
const content = message.content;
const blocks = Array.isArray(content) ? content : [content];
let text = "";
let imageUri: string | undefined;
let audioUri: string | undefined;
let audioMime: string | undefined;
for (const block of blocks) {
switch (block.type) {
case "text":
text += block.text;
break;
case "image":
imageUri = buildDataUri(block.mimeType, block.data);
break;
case "audio":
audioUri = buildDataUri(block.mimeType, block.data);
audioMime = block.mimeType;
break;
case "resource":
text +=
"text" in block.resource
? block.resource.text
: `[resource: ${block.resource.uri}]`;
break;
case "resource_link":
text += `[resource: ${block.uri}]`;
break;
default:
text += `[${block.type}]`;
break;
}
}
return { text, imageUri, audioUri, audioMime };
}
const BubbleContainer = Paper.withProps({
p: "md",
radius: "md",
withBorder: true,
});
const RoleLabel = Text.withProps({
size: "xs",
c: "dimmed",
ff: "monospace",
});
const PreviewImage = Image.withProps({
maw: 300,
radius: "sm",
mt: "xs",
});
export function MessageBubble({ index, message }: MessageBubbleProps) {
const { text, imageUri, audioUri, audioMime } = extractContent(message);
return (
<BubbleContainer>
<Stack gap="xs">
<Group justify="space-between">
<RoleLabel>{formatRoleLabel(index, message.role)}</RoleLabel>
{text && <CopyButton value={text} />}
</Group>
{text && <Text size="sm">{formatQuotedContent(text)}</Text>}
{imageUri && <PreviewImage src={imageUri} />}
{audioUri && (
<audio controls>
<source src={audioUri} type={audioMime} />
</audio>
)}
</Stack>
</BubbleContainer>
);
}