diff --git a/web/src/components/Chat/MessageBubble.tsx b/web/src/components/Chat/MessageBubble.tsx index f5dd187..f67c928 100644 --- a/web/src/components/Chat/MessageBubble.tsx +++ b/web/src/components/Chat/MessageBubble.tsx @@ -1,4 +1,5 @@ -import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download } from 'lucide-react' +import { useState } from 'react' +import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import type { ChatMessage, Attachment } from '../../types/protocol' @@ -22,6 +23,13 @@ function getFileName(path: string): string { return parts[parts.length - 1] || path } +function formatTime(timestamp: number) { + return new Date(timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }) +} + function AttachmentCard({ attachment }: { attachment: Attachment }) { const fileName = attachment.file_name || getFileName(attachment.path) @@ -72,9 +80,184 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) { ) } +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 ( + + ) +} + export function MessageBubble({ message }: MessageBubbleProps) { const isUser = message.role === 'user' const isTool = message.role === 'tool' + const isMergedTool = message.type === 'merged_tool' + const [toolExpanded, setToolExpanded] = useState(false) + + if (isMergedTool) { + const status = message.status || 'calling' + const hasResult = !!(message.resultContent) + const hasArgs = message.arguments !== undefined && message.arguments !== null + + const statusConfig = { + calling: { + dot: 'bg-amber-400 animate-pulse', + label: '执行中', + fullBorder: 'border-amber-500/30', + labelColor: 'text-amber-400', + avatarBg: 'bg-amber-500/20', + avatarIcon: 'text-amber-400', + }, + result: { + dot: 'bg-emerald-400', + label: '已完成', + fullBorder: 'border-emerald-500/30', + labelColor: 'text-emerald-400', + avatarBg: 'bg-emerald-500/20', + avatarIcon: 'text-emerald-400', + }, + pending: { + dot: 'bg-orange-400 animate-pulse', + label: '待确认', + fullBorder: 'border-orange-500/30', + labelColor: 'text-orange-400', + avatarBg: 'bg-orange-500/20', + avatarIcon: 'text-orange-400', + }, + }[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!) : '' + + return ( +
+ {JSON.stringify(message.arguments, null, 2)}
+
+
+ {formatJSON(displayContent)}
+
+