PicoBot/web/src/components/Chat/MessageBubble.tsx
oudecheng 73c25e5a20 feat(web): Task 卡片差异显示子代理模型名 + 子代理完成状态转发修复
- Task/tool 卡片头部在子代理模型与主代理不同(model 或 provider 任一不同)时显示模型徽章
- useChat 放行带 subagent_task_id 的 execution_completed 消息,更新卡片 running→completed 状态
2026-08-15 15:35:32 +08:00

969 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, memo } from 'react';
import {
CheckCircle,
ChevronRight,
ChevronDown,
Clock,
Loader,
Loader2,
XCircle,
Copy,
Check,
Brain,
Maximize2,
Download,
File,
FileText,
Image,
Music,
Video,
X,
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol';
import { ToolDetailModal } from './ToolDetailModal';
// 状态图标组件
function StatusIcon({
status,
size = 14,
}: {
status: string;
size?: number;
}) {
const iconClass = `transition-all duration-300`;
switch (status) {
case 'calling':
case 'running':
return (
<Loader2
className={`${iconClass} animate-spin`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
);
case 'result':
case 'success':
case 'completed':
return (
<CheckCircle
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
);
case 'failed':
return (
<XCircle
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
);
case 'timeout':
return (
<Clock
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
);
case 'pending':
case 'interrupted':
case 'cancelled':
return (
<Loader
className={`${iconClass} animate-spin`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
);
default:
return null;
}
}
interface MessageBubbleProps {
message: ChatMessage;
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void;
showThinking?: boolean;
/** 主代理当前生效模型Task 卡片差异显示:子代理模型不同才展示模型徽章) */
effectiveModel?: { provider: string; model: string } | null;
}
function getAttachmentIcon(mediaType: string) {
switch (mediaType) {
case 'image':
return <Image className="h-4 w-4" />;
case 'audio':
return <Music className="h-4 w-4" />;
case 'video':
return <Video className="h-4 w-4" />;
case 'file':
return <FileText className="h-4 w-4" />;
default:
return <File className="h-4 w-4" />;
}
}
function getFileName(path: string): string {
const parts = path.replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || path;
}
function formatTime(timestamp: number) {
return new Date(timestamp * 1000).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
}
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
}
if (ms < 60000) {
return `${(ms / 1000).toFixed(1)}s`;
}
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
function AttachmentCard({ attachment }: { attachment: Attachment }) {
const fileName = attachment.file_name || getFileName(attachment.path);
const handleDownload = (e: React.MouseEvent) => {
if (!attachment.content_base64) return;
e.preventDefault();
const mimeType = attachment.mime_type || 'application/octet-stream';
const byteChars = atob(attachment.content_base64);
const byteNums = new Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteNums[i] = byteChars.charCodeAt(i);
}
const byteArr = new Uint8Array(byteNums);
const blob = new Blob([byteArr], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const canDownload = !!attachment.content_base64;
return (
<div
onClick={canDownload ? handleDownload : undefined}
className={`flex items-center gap-2 rounded-lg bg-[var(--overlay-hover)] border border-[var(--border-color)] px-3 py-2 text-xs transition-colors group ${
canDownload
? 'hover:bg-[var(--overlay-subtle)] hover:border-[var(--accent-cyan)]/30 cursor-pointer'
: ''
}`}
title={canDownload ? `下载 ${fileName}` : attachment.path}
>
<span
className={`text-[var(--text-secondary)] transition-colors ${canDownload ? 'group-hover:text-[var(--accent-cyan)]' : ''}`}
>
{getAttachmentIcon(attachment.media_type)}
</span>
<span
className={`text-[var(--text-secondary)] truncate max-w-[200px] transition-colors ${canDownload ? 'group-hover:text-[var(--text-primary)]' : ''}`}
title={attachment.path}
>
{fileName}
</span>
<span className="text-[var(--text-muted)] ml-auto shrink-0">{attachment.media_type}</span>
{canDownload && (
<Download className="h-3 w-3 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors" />
)}
</div>
);
}
function ImageLightbox({
src,
mimeType,
fileName,
onClose,
}: {
src: string;
mimeType: string;
fileName?: string;
onClose: () => void;
}) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
const byteChars = atob(src);
const byteNums = new Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteNums[i] = byteChars.charCodeAt(i);
}
const byteArr = new Uint8Array(byteNums);
const blob = new Blob([byteArr], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName || `image.${mimeType.split('/')[1] || 'png'}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
{/* 顶部工具栏 */}
<div className="absolute top-4 right-4 flex items-center gap-2 z-10">
<button
onClick={handleDownload}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
aria-label="下载图片"
title="下载图片"
>
<Download className="h-5 w-5" />
</button>
<button
onClick={onClose}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
aria-label="关闭"
title="关闭"
>
<X className="h-5 w-5" />
</button>
</div>
<img
src={`data:${mimeType};base64,${src}`}
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
alt="图片预览"
/>
</div>
);
}
function CopyButton({ text, className = '' }: { text: string; className?: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {
// fallback silently
});
};
if (!text) return null;
return (
<button
onClick={handleCopy}
className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[var(--overlay-subtle)] flex-shrink-0 ${className}`}
title="复制"
>
{copied ? (
<Check className="h-3 w-3 text-[var(--accent-green)]" />
) : (
<Copy className="h-3 w-3 text-[var(--text-muted)]" />
)}
</button>
);
}
function ThinkingSection({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-[var(--border-color)] bg-[var(--overlay-hover)] overflow-hidden">
<button
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-subtle)] transition-colors cursor-pointer select-none"
>
<Brain className="h-3.5 w-3.5 text-[var(--accent-purple)] flex-shrink-0" />
<span className="font-medium text-[var(--accent-purple)]">Thinking</span>
{expanded ? (
<ChevronDown className="h-3 w-3 ml-auto flex-shrink-0" />
) : (
<ChevronRight className="h-3 w-3 ml-auto flex-shrink-0" />
)}
{!expanded && (
<span className="text-[var(--text-muted)] truncate flex-1 text-left">
{content.slice(0, 60)}
{content.length > 60 ? '…' : ''}
</span>
)}
</button>
{expanded && (
<div className="px-3 py-2 border-t border-[var(--border-color)] animate-thinking-reveal">
<div className="text-xs text-[var(--text-secondary)] whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto scrollbar-thin">
{content}
</div>
</div>
)}
</div>
);
}
function parseTaskResult(content: string): TaskToolResult | null {
if (!content) return null;
try {
const parsed = JSON.parse(content);
if (
parsed &&
typeof parsed.status === 'string' &&
typeof parsed.output === 'string' &&
typeof parsed.summary === 'string' &&
typeof parsed.task_id === 'string'
) {
return parsed as TaskToolResult;
}
return null;
} catch {
return null;
}
}
export const MessageBubble = memo(function MessageBubble({
message,
onNavigateToSubAgent,
showThinking = true,
effectiveModel,
}: MessageBubbleProps) {
const isUser = message.role === 'user';
const isTool = message.role === 'tool';
const isMergedTool = message.type === 'merged_tool';
const [toolExpanded, setToolExpanded] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [lightboxImage, setLightboxImage] = useState<{
base64: string;
mimeType: string;
fileName?: string;
} | null>(null);
const lightboxElement = lightboxImage ? (
<ImageLightbox
src={lightboxImage.base64}
mimeType={lightboxImage.mimeType}
fileName={lightboxImage.fileName}
onClose={() => setLightboxImage(null)}
/>
) : null;
if (isMergedTool) {
const status = message.status || 'calling';
const hasResult = !!message.resultContent;
const hasArgs = message.arguments !== undefined && message.arguments !== null;
const statusConfig = {
calling: {
dot: 'bg-[var(--accent-cyan)] animate-pulse',
fullBorder: 'border-[var(--accent-cyan)]/30',
iconColor: 'text-[var(--accent-cyan)]',
avatarBg: 'bg-[var(--accent-cyan)]/10',
avatarIcon: 'text-[var(--accent-cyan)]',
},
result: {
dot: 'bg-[var(--accent-green)]',
fullBorder: 'border-[var(--accent-green)]/30',
iconColor: 'text-[var(--accent-green)]',
avatarBg: 'bg-[var(--accent-green)]/10',
avatarIcon: 'text-[var(--accent-green)]',
},
pending: {
dot: 'bg-[var(--accent-amber)] animate-pulse',
fullBorder: 'border-[var(--accent-amber)]/30',
iconColor: 'text-[var(--accent-amber)]',
avatarBg: 'bg-[var(--accent-amber)]/10',
avatarIcon: 'text-[var(--accent-amber)]',
},
}[status];
const formatJSON = (text: string): string => {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
};
function stripToolResultPrefix(text: string): string {
const lines = text.split('\n');
if (lines[0]?.startsWith('工具结果')) {
let start = 1;
while (start < lines.length && lines[start].trim() === '') {
start++;
}
return lines.slice(start).join('\n');
}
return text;
}
const argsPreview = hasArgs ? JSON.stringify(message.arguments).slice(0, 500) : '';
const displayContent = hasResult ? stripToolResultPrefix(message.resultContent!) : '';
const isTaskTool = message.toolName === 'task';
const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null;
const subagentType =
((message.arguments as Record<string, unknown> | null)?.subagent_type as string) || 'general';
const taskDescription =
((message.arguments as Record<string, unknown> | null)?.description as string) || '';
const taskPrompt =
((message.arguments as Record<string, unknown> | null)?.prompt as string) || '';
// task tool 专用的状态配色
// 支持的状态:
// - running: 异步子代理刚 spawn占位结果
// - success/completed: 子代理执行成功
// - failed: 子代理执行失败
// - timeout: 子代理执行超时
// - cancelled: 子代理被用户取消
// - interrupted: 子代理因服务器重启被中断
const taskStatusConfig: Record<string, { dot: string; borderColor: string; iconColor: string }> = {
running: {
dot: 'bg-[var(--accent-amber)] animate-pulse',
borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-[var(--accent-amber)]',
},
success: {
dot: 'bg-[var(--accent-green)]',
borderColor: 'border-[var(--accent-green)]/40',
iconColor: 'text-[var(--accent-green)]',
},
completed: {
dot: 'bg-[var(--accent-green)]',
borderColor: 'border-[var(--accent-green)]/40',
iconColor: 'text-[var(--accent-green)]',
},
failed: {
dot: 'bg-[rgb(242,90,90)]',
borderColor: 'border-[rgb(242,90,90)]/40',
iconColor: 'text-[rgb(242,90,90)]',
},
timeout: {
dot: 'bg-[var(--accent-amber)]',
borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-[var(--accent-amber)]',
},
cancelled: {
dot: 'bg-[var(--text-muted)]',
borderColor: 'border-[var(--border-color)]',
iconColor: 'text-[var(--text-muted)]',
},
interrupted: {
dot: 'bg-[var(--accent-amber)]',
borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-[var(--accent-amber)]',
},
};
// 安全获取 task 状态配色,未知状态回退到默认(避免 undefined.borderColor 崩溃)
const taskStyle = taskResult ? (taskStatusConfig[taskResult.status] ?? taskStatusConfig.failed) : null;
// 子代理模型徽章:结果携带模型信息,且(无主代理参照 或 model/provider 任一不同)时展示。
// provider 不同即使 model 同名也显示——不同 provider 的同名模型在路由/计费上已不同。
const modelDiffers =
!effectiveModel ||
effectiveModel.model !== taskResult?.model ||
effectiveModel.provider !== taskResult?.provider;
const subagentModelLabel =
taskResult?.model && modelDiffers
? effectiveModel && effectiveModel.provider === taskResult.provider
? taskResult.model
: `${taskResult.provider ?? '?'}/${taskResult.model}`
: null;
return (
<div data-message-id={message.id} className="animate-slide-in">
<div
onClick={() => setToolExpanded(!toolExpanded)}
className={`group w-full cursor-pointer rounded-xl border bg-[var(--bg-secondary)]/60 transition-colors duration-300 hover:bg-[var(--bg-secondary)] ${
taskStyle ? taskStyle.borderColor : statusConfig.fullBorder
}`}
>
{/* Header row */}
<div className="flex items-center gap-2 px-3 py-2">
<span
className={`inline-block h-2 w-2 rounded-full flex-shrink-0 transition-colors duration-500 ${
taskStyle ? taskStyle.dot : statusConfig.dot
}`}
/>
<span className="text-sm font-medium text-[var(--text-secondary)] truncate">
{isTaskTool
? `${message.toolName || 'Tool'}${taskDescription ? ` · ${taskDescription}` : ''}`
: message.toolName || 'Tool'}
</span>
{subagentModelLabel && (
<span
className="flex-shrink-0 rounded-full border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-1.5 py-px text-[10px] leading-4 text-[var(--text-muted)] max-w-[160px] truncate"
title={`子代理模型:${taskResult?.provider ?? '?'} / ${taskResult?.model}`}
>
{subagentModelLabel}
</span>
)}
<span
className={`flex-shrink-0 transition-all duration-300 ${
taskStyle ? taskStyle.iconColor : statusConfig.iconColor
}`}
>
{taskResult ? (
<StatusIcon status={taskResult.status} />
) : (
<StatusIcon status={status} />
)}
</span>
{status === 'result' && message.durationMs != null && (
<span className="text-xs text-[var(--text-muted)] flex-shrink-0 tabular-nums ml-1">
{formatDuration(message.durationMs)}
</span>
)}
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
<button
onClick={(e) => {
e.stopPropagation();
setShowDetailModal(true);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[var(--overlay-subtle)] flex-shrink-0"
title="放大查看"
>
<Maximize2 className="h-3 w-3 text-[var(--text-muted)]" />
</button>
<span className="ml-auto flex-shrink-0">
{toolExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-[var(--text-muted)]" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />
)}
</span>
</div>
{/* 模型思考内容(工具调用时展示) */}
{showThinking && message.reasoningContent && !toolExpanded && (
<div className="px-3 pb-1 mb-2">
<ThinkingSection content={message.reasoningContent} />
</div>
)}
{/* Collapsed preview */}
{!toolExpanded && (
<>
{hasArgs && argsPreview && !taskResult && (
<div
className="px-3 pb-1 text-xs text-[var(--text-muted)] font-mono line-clamp-3"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{argsPreview}
</div>
)}
{taskResult && taskResult.summary && (
<div className="px-3 pb-1 text-xs text-[var(--text-secondary)] line-clamp-2">
{taskResult.summary}
</div>
)}
{taskResult && (
<div className="px-3 pb-1">
<button
onClick={(e) => {
e.stopPropagation();
onNavigateToSubAgent?.(
taskResult.task_id,
taskDescription || '子智能体任务',
subagentType,
);
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
<span></span>
<span></span>
</button>
</div>
)}
{isTaskTool && message.navigateToTaskId && !taskResult && (
<div className="px-3 pb-1">
<button
onClick={(e) => {
e.stopPropagation();
onNavigateToSubAgent?.(
message.navigateToTaskId!,
taskDescription || '子智能体任务',
subagentType,
);
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
<span></span>
<span></span>
</button>
</div>
)}
{isTaskTool && !taskResult && !message.navigateToTaskId && (
<div className="px-3 pb-2 text-xs text-[var(--text-muted)]">
...
</div>
)}
</>
)}
{/* Expanded */}
{toolExpanded && (
<div className="border-t border-[var(--border-color)] px-3 py-2 space-y-2">
{/* 模型思考内容(展开时也展示) */}
{showThinking && message.reasoningContent && (
<div className="mb-2">
<ThinkingSection content={message.reasoningContent} />
</div>
)}
{taskResult ? (
<>
{taskPrompt && (
<div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">
</div>
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto max-h-32 overflow-y-auto">
{taskPrompt}
</pre>
</div>
)}
{taskResult.summary && (
<div
className={`rounded-lg px-3 py-2 ${
taskResult.status === 'success'
? 'bg-[var(--accent-green)]/10 border border-[var(--accent-green)]/30'
: taskResult.status === 'failed'
? 'bg-[rgb(242,90,90)]/10 border border-[rgb(242,90,90)]/30'
: 'bg-[var(--accent-amber)]/10 border border-[var(--accent-amber)]/30'
}`}
>
<div className="text-xs font-medium text-[var(--text-muted)] mb-0.5">
</div>
<div className="text-sm text-[var(--text-secondary)]">
{taskResult.summary}
</div>
</div>
)}
<div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1"></div>
<div className="markdown-content text-sm leading-relaxed bg-[var(--overlay-dim)] rounded-lg p-3 max-h-96 overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{taskResult.output}
</ReactMarkdown>
</div>
</div>
<div className="text-xs text-[var(--text-muted)] font-mono select-all">
task_id: {taskResult.task_id}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onNavigateToSubAgent?.(
taskResult.task_id,
taskDescription || '子智能体任务',
subagentType,
);
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
<span></span>
<span></span>
</button>
</>
) : (
<>
{hasArgs && (
<div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">
</div>
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto">
{JSON.stringify(message.arguments, null, 2)}
</pre>
</div>
)}
{hasResult && (
<div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">
</div>
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto max-h-48 overflow-y-auto">
{formatJSON(displayContent)}
</pre>
</div>
)}
{isTaskTool && message.navigateToTaskId && (
<button
onClick={(e) => {
e.stopPropagation();
onNavigateToSubAgent?.(
message.navigateToTaskId!,
taskDescription || '子智能体任务',
subagentType,
);
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
<span></span>
<span></span>
</button>
)}
</>
)}
</div>
)}
</div>
{lightboxElement}
{showDetailModal && (
<ToolDetailModal
toolName={message.toolName || 'Tool'}
status={status}
statusLabel={
status === 'calling'
? '执行中'
: status === 'result'
? '已完成'
: status === 'pending'
? '待确认'
: status
}
arguments={message.arguments}
resultContent={message.resultContent || ''}
callContent={message.callContent || ''}
durationMs={message.durationMs}
onClose={() => setShowDetailModal(false)}
/>
)}
</div>
);
}
// 隐藏无可见内容的助手消息(无文本,且无思考或思考被关闭)
const hasVisibleContent =
!!(message.content && message.content.trim()) || (showThinking && message.reasoningContent);
if (!isUser && !isTool && !isMergedTool && !hasVisibleContent) {
return null;
}
return (
<div
data-message-id={message.id}
className={`animate-slide-in group ${isUser ? 'flex flex-col items-end' : ''}`}
>
{!isUser && (
<div className="mb-1 flex items-center gap-2">
{isTool && message.toolName ? (
<span className="text-xs font-medium text-[var(--text-secondary)]">
{message.toolName}
</span>
) : null}
<span className="text-xs text-[var(--text-muted)] opacity-0 transition-opacity group-hover:opacity-100">
{formatTime(message.timestamp)}
</span>
<CopyButton text={message.content} />
</div>
)}
<div
className={
isUser
? 'max-w-[min(525px,82%)] rounded-[22px] bg-[var(--bubble-user)] px-4 py-2.5 text-left'
: 'w-full min-w-0'
}
>
{isUser ? (
// 用户消息保持纯文本(右对齐柔和气泡)
<div className="whitespace-pre-wrap text-[15px] leading-6 text-[var(--text-primary)]">
{message.content}
</div>
) : (
// 模型思考内容(仅助手消息,非工具消息)
<>
{showThinking && !isTool && message.reasoningContent && (
<div className={message.content.trim() ? 'mb-3' : ''}>
<ThinkingSection content={message.reasoningContent} />
</div>
)}
{/* AI 和工具消息使用 Markdown 渲染 */}
{message.content.trim() && (
<div className="markdown-content text-[15px] leading-6">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 自定义代码块渲染
code({ className, children, ...props }) {
const isInline = !className;
if (isInline) {
return (
<code
className="bg-[var(--overlay-code)] px-1.5 py-0.5 rounded text-[var(--accent-cyan)] font-mono text-xs"
{...props}
>
{children}
</code>
);
}
return (
<pre className="bg-[var(--overlay-dim-heavy)] rounded-lg p-3 overflow-x-auto my-2">
<code className={`${className} font-mono text-xs`} {...props}>
{children}
</code>
</pre>
);
},
// 标题样式
h1: ({ children }) => (
<h1 className="text-xl font-bold text-[var(--text-primary)] mb-2 mt-4">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold text-[var(--text-primary)] mb-2 mt-3">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-bold text-[var(--text-primary)] mb-1 mt-2">
{children}
</h3>
),
// 段落
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
// 列表
ul: ({ children }) => (
<ul className="list-disc list-outside mb-2 space-y-1 pl-5">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-outside mb-2 space-y-1 pl-5">
{children}
</ol>
),
li: ({ children }) => <li className="[&>p]:m-0">{children}</li>,
// 链接
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent-cyan)] hover:underline"
>
{children}
</a>
),
// 表格
table: ({ children }) => (
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
),
thead: ({ children }) => (
<thead className="bg-[var(--overlay-subtle)]">{children}</thead>
),
th: ({ children }) => (
<th className="border border-[var(--border-color)] px-2 py-1 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-[var(--border-color)] px-2 py-1">
{children}
</td>
),
// 引用块
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-[var(--accent-cyan)]/50 pl-3 my-2 text-[var(--text-secondary)]">
{children}
</blockquote>
),
// 分隔线
hr: () => <hr className="border-[var(--border-color)] my-3" />,
// 加粗和斜体
strong: ({ children }) => (
<strong className="font-bold text-[var(--text-primary)]">{children}</strong>
),
em: ({ children }) => (
<em className="italic text-[var(--text-secondary)]">{children}</em>
),
}}
>
{message.content}
</ReactMarkdown>
</div>
)}
</>
)}
{message.attachments && message.attachments.length > 0 && (
<div className="mt-2 space-y-2">
{message.attachments.some(
(att) => att.media_type === 'image' && att.content_base64,
) && (
<div className="flex flex-wrap gap-2">
{message.attachments
.filter((att) => att.media_type === 'image' && att.content_base64)
.map((att, idx) => (
<img
key={`img-${idx}`}
src={`data:${att.mime_type || 'image/png'};base64,${att.content_base64}`}
alt={att.file_name || att.path || '图片'}
className="max-w-64 max-h-48 rounded-xl object-cover cursor-pointer border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/40 hover:scale-[1.02] transition-all duration-200 shadow-md"
onClick={() =>
setLightboxImage({
base64: att.content_base64!,
mimeType: att.mime_type || 'image/png',
fileName: att.file_name || att.path,
})
}
/>
))}
</div>
)}
{message.attachments
.filter((att) => att.media_type !== 'image' || !att.content_base64)
.map((att, idx) => (
<AttachmentCard key={`att-${idx}`} attachment={att} />
))}
</div>
)}
</div>
{isUser && (
<div className="mt-1 flex items-center gap-2 px-1">
<span className="text-xs text-[var(--text-muted)] opacity-0 transition-opacity group-hover:opacity-100">
{formatTime(message.timestamp)}
</span>
<CopyButton text={message.content} />
</div>
)}
{lightboxElement}
</div>
);
});