From 182bebdaeff42c7d135f7fc3f406fe71736651cb Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Fri, 29 May 2026 09:10:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E5=85=B7=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E4=B8=BA=E5=8F=AF=E5=B1=95=E5=BC=80=E5=8D=A1?= =?UTF-8?q?=E7=89=87=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=A4=8D=E5=88=B6=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 merged_tool 消息类型,将 tool_call 和 tool_result 合并展示 - 卡片支持展开/折叠,显示参数和结果,带状态动画 - 添加复制按钮(hover 显示),支持消息文本和工具结果复制 - 过滤结果中冗余的"工具结果"前缀 Co-Authored-By: Claude Opus 4.7 --- web/src/components/Chat/MessageBubble.tsx | 195 +++++++++++++++++++++- web/src/types/protocol.ts | 5 +- 2 files changed, 190 insertions(+), 10 deletions(-) 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 ( +
+
+ +
+
+
+ {message.toolName || 'Tool'} + {formatTime(message.timestamp)} +
+
setToolExpanded(!toolExpanded)} + className={`cursor-pointer rounded-xl border bg-[#1a1a25]/60 w-full transition-all duration-500 hover:bg-[#1a1a25]/80 group ${statusConfig.fullBorder}`} + > + {/* Header row */} +
+ + {message.toolName || 'Tool'} + + {statusConfig.label} + + {hasResult && } + + {toolExpanded ? ( + + ) : ( + + )} + +
+ + {/* Collapsed preview */} + {!toolExpanded && ( + <> + {hasArgs && argsPreview && ( +
+ {argsPreview} +
+ )} + {hasResult ? ( +
+ 点击查看工具结果 +
+ ) : ( +
等待工具执行...
+ )} + + )} + + {/* Expanded */} + {toolExpanded && ( +
+ {hasArgs && ( +
+
参数
+
+                      {JSON.stringify(message.arguments, null, 2)}
+                    
+
+ )} + {hasResult && ( +
+
结果
+
+                      {formatJSON(displayContent)}
+                    
+
+ )} + {!hasArgs && !hasResult && ( +
等待工具执行...
+ )} +
+ )} +
+
+
+ ) + } const getIcon = () => { if (isUser) return @@ -111,15 +294,8 @@ export function MessageBubble({ message }: MessageBubbleProps) { return 'bg-gradient-to-br from-[#8b5cf6] to-[#ec4899]' } - const formatTime = (timestamp: number) => { - return new Date(timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }) - } - return ( -
+
@@ -131,6 +307,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { {isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'} {formatTime(message.timestamp)} +