Files
sproutclaw-web/frontend/src/components/chat/MessageBubble.tsx
2026-06-13 20:16:09 +08:00

205 lines
5.4 KiB
TypeScript

import { useState } from "react";
import type { ChatMessage } from "../../types/message";
import { useAvatars } from "../../context/AvatarContext";
import {
downloadTextFile,
messageMarkdownFilename,
singleAssistantMarkdown,
} from "../../utils/exportConversation";
import { renderMarkdown } from "../../utils/markdown";
import { imageToDataUrl } from "../../utils/images";
import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
import { ToolCallBlock } from "./ToolCallBlock";
import { ThinkingBlock } from "./ThinkingBlock";
import { BashOutputBlock } from "./BashOutputBlock";
import styles from "./MessageList.module.css";
interface MessageBubbleProps {
message: ChatMessage;
agentGutter?: "avatar" | "spacer" | "none";
toolsExpanded?: boolean;
}
function ChatAvatar({ src, className }: { src: string; className: string }) {
const [failed, setFailed] = useState(false);
if (!src || failed) return null;
return (
<img
className={className}
src={src}
width={32}
height={32}
alt=""
decoding="async"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
/>
);
}
function AgentSideGutter({ mode, avatarUrl }: { mode: "avatar" | "spacer"; avatarUrl: string }) {
if (mode === "spacer") {
return <div className={styles.assistantAvatarSpacer} aria-hidden="true" />;
}
return (
<div className={styles.assistantAvatarSlot}>
<ChatAvatar src={avatarUrl} className={styles.assistantAvatar} />
</div>
);
}
function AssistantActions({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
const markdown = singleAssistantMarkdown(content);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(markdown);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
const handleDownload = () => {
downloadTextFile(messageMarkdownFilename(content), markdown, "text/markdown");
};
return (
<div className={styles.assistantToolbar}>
<button
type="button"
className={`${styles.msgActionBtn} ${copied ? styles.msgActionBtnCopied : ""}`}
onClick={() => void handleCopy()}
title="复制 Markdown"
>
{copied ? "已复制" : "复制"}
</button>
<button
type="button"
className={styles.msgActionBtn}
onClick={handleDownload}
title="下载 Markdown"
>
</button>
</div>
);
}
export function MessageBubble({
message,
agentGutter = "none",
toolsExpanded = false,
}: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
const gutter =
agentGutter === "avatar" || agentGutter === "spacer" ? (
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
) : null;
if (message.role === "thinking") {
return (
<div className={styles.assistantRow}>
{gutter}
<div className={`${styles.msg} ${styles.thinking}`}>
<ThinkingBlock content={message.content} streaming={message.streaming} />
</div>
</div>
);
}
if (message.role === "tool_call") {
return (
<div className={styles.assistantRow}>
{gutter}
<div className={`${styles.msg} ${styles.toolCall}`}>
<ToolCallBlock content={message.content} forceOpen={toolsExpanded} />
</div>
</div>
);
}
if (message.role === "bash") {
return (
<div className={styles.assistantRow}>
{gutter}
<div className={`${styles.msg} ${styles.bash}`}>
<BashOutputBlock
command={message.bashCommand}
content={message.content}
exitCode={message.bashExitCode}
streaming={message.streaming}
excludeFromContext={message.bashExcludeFromContext}
/>
</div>
</div>
);
}
if (message.role === "compaction_summary") {
return (
<div className={`${styles.msg} ${styles.system}`}>
<CompactionSummaryBlock content={message.content} />
</div>
);
}
if (message.role === "branch_summary") {
return (
<div className={`${styles.msg} ${styles.system}`}>
<CompactionSummaryBlock content={message.content} label="分支摘要" />
</div>
);
}
const classNames = [styles.msg];
if (message.role === "user") classNames.push(styles.user);
if (message.role === "assistant") classNames.push(styles.assistant);
if (message.role === "system") classNames.push(styles.system);
if (message.streaming) classNames.push(styles.streaming);
if (message.role === "assistant") {
const showActions = !message.streaming && message.content.trim().length > 0;
return (
<div className={styles.assistantRow}>
{gutter}
<div className={classNames.join(" ")}>
{showActions ? <AssistantActions content={message.content} /> : null}
<div
className={styles.assistantBody}
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
</div>
</div>
);
}
if (message.role === "user") {
return (
<div className={styles.userRow}>
<div className={classNames.join(" ")}>
{message.content ? <div>{message.content}</div> : null}
{message.images?.length ? (
<div className={styles.userImages}>
{message.images.map((img, index) => (
<img
key={`${img.mimeType}-${index}`}
className={styles.userImage}
src={imageToDataUrl(img)}
alt=""
loading="lazy"
/>
))}
</div>
) : null}
</div>
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
</div>
);
}
return <div className={classNames.join(" ")}>{message.content}</div>;
}