feat: init sproutclaw-web — Go+Gin backend + React frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
204
frontend/src/components/chat/MessageBubble.tsx
Normal file
204
frontend/src/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user