feat: 工具消息合并为可展开卡片,添加复制按钮
- 新增 merged_tool 消息类型,将 tool_call 和 tool_result 合并展示 - 卡片支持展开/折叠,显示参数和结果,带状态动画 - 添加复制按钮(hover 显示),支持消息文本和工具结果复制 - 过滤结果中冗余的"工具结果"前缀 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
34011a6fa3
commit
182bebdaef
@ -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 ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import type { ChatMessage, Attachment } from '../../types/protocol'
|
import type { ChatMessage, Attachment } from '../../types/protocol'
|
||||||
@ -22,6 +23,13 @@ function getFileName(path: string): string {
|
|||||||
return parts[parts.length - 1] || path
|
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 }) {
|
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||||||
const fileName = attachment.file_name || getFileName(attachment.path)
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-white/10 flex-shrink-0 ${className}`}
|
||||||
|
title="复制"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3 w-3 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3 text-zinc-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||||
const isUser = message.role === 'user'
|
const isUser = message.role === 'user'
|
||||||
const isTool = message.role === 'tool'
|
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 (
|
||||||
|
<div className="flex gap-3 animate-slide-in">
|
||||||
|
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${statusConfig.avatarBg}`}>
|
||||||
|
<Terminal className={`h-3.5 w-3.5 ${statusConfig.avatarIcon}`} />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[80%] min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-medium text-zinc-400">{message.toolName || 'Tool'}</span>
|
||||||
|
<span className="text-xs text-zinc-600">{formatTime(message.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => 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 */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full flex-shrink-0 ${statusConfig.dot} transition-colors duration-500`} />
|
||||||
|
<span className="text-sm font-medium text-zinc-300 truncate">{message.toolName || 'Tool'}</span>
|
||||||
|
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${statusConfig.labelColor}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
{hasResult && <CopyButton text={displayContent} />}
|
||||||
|
<span className="ml-auto flex-shrink-0">
|
||||||
|
{toolExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-zinc-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-zinc-500" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsed preview */}
|
||||||
|
{!toolExpanded && (
|
||||||
|
<>
|
||||||
|
{hasArgs && argsPreview && (
|
||||||
|
<div className="px-3 pb-1 text-xs text-zinc-500 font-mono line-clamp-3"
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{argsPreview}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasResult ? (
|
||||||
|
<div className="px-3 pb-2 text-xs text-[#00f0ff]/50 flex items-center gap-1 select-none">
|
||||||
|
<span>点击查看工具结果</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-3 pb-2 text-xs text-zinc-500">等待工具执行...</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded */}
|
||||||
|
{toolExpanded && (
|
||||||
|
<div className="border-t border-white/8 px-3 py-2 space-y-2">
|
||||||
|
{hasArgs && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-1">参数</div>
|
||||||
|
<pre className="text-xs text-zinc-400 font-mono whitespace-pre-wrap bg-black/20 rounded-lg p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(message.arguments, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasResult && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-500 mb-1">结果</div>
|
||||||
|
<pre className="text-xs text-zinc-400 font-mono whitespace-pre-wrap bg-black/20 rounded-lg p-2 overflow-x-auto max-h-48 overflow-y-auto">
|
||||||
|
{formatJSON(displayContent)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasArgs && !hasResult && (
|
||||||
|
<div className="text-xs text-zinc-500">等待工具执行...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
if (isUser) return <User className="h-4 w-4" />
|
if (isUser) return <User className="h-4 w-4" />
|
||||||
@ -111,15 +294,8 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
return 'bg-gradient-to-br from-[#8b5cf6] to-[#ec4899]'
|
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 (
|
return (
|
||||||
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in`}>
|
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
|
||||||
<div
|
<div
|
||||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
|
||||||
>
|
>
|
||||||
@ -131,6 +307,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
|
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-zinc-600">{formatTime(message.timestamp)}</span>
|
<span className="text-xs text-zinc-600">{formatTime(message.timestamp)}</span>
|
||||||
|
<CopyButton text={message.content} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`inline-block rounded-2xl border px-5 py-3 text-left shadow-lg ${getContainerStyles()}`}
|
className={`inline-block rounded-2xl border px-5 py-3 text-left shadow-lg ${getContainerStyles()}`}
|
||||||
|
|||||||
@ -248,11 +248,14 @@ export interface ChatMessage {
|
|||||||
role: 'user' | 'assistant' | 'tool'
|
role: 'user' | 'assistant' | 'tool'
|
||||||
content: string
|
content: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
|
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' | 'merged_tool'
|
||||||
toolName?: string
|
toolName?: string
|
||||||
toolCallId?: string
|
toolCallId?: string
|
||||||
arguments?: unknown
|
arguments?: unknown
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
|
status?: 'calling' | 'result' | 'pending'
|
||||||
|
resultContent?: string
|
||||||
|
callContent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Topic {
|
export interface Topic {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user