205 lines
5.4 KiB
TypeScript
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>;
|
|
}
|