对 47 个前端文件统一执行 npm run format,消除存量格式差异。 随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import {
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Play,
|
||
Check,
|
||
AlertTriangle,
|
||
Terminal,
|
||
Maximize2,
|
||
} from 'lucide-react';
|
||
import { useState, useMemo } from 'react';
|
||
import type { ChatMessage } from '../../types/protocol';
|
||
import { ToolDetailModal } from '../Chat/ToolDetailModal';
|
||
|
||
interface ToolPanelProps {
|
||
messages: ChatMessage[];
|
||
}
|
||
|
||
interface ToolCallItem {
|
||
toolCallId: string;
|
||
toolName: string;
|
||
status: 'calling' | 'result' | 'pending';
|
||
arguments?: unknown;
|
||
resultContent: string;
|
||
callContent: string;
|
||
durationMs?: number;
|
||
}
|
||
|
||
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 formatResultText(content: string): string {
|
||
if (!content) return '';
|
||
try {
|
||
const parsed = JSON.parse(content);
|
||
return JSON.stringify(parsed, null, 2);
|
||
} catch {
|
||
return content;
|
||
}
|
||
}
|
||
|
||
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
||
const map = new Map<string, ToolCallItem>();
|
||
|
||
for (const m of messages) {
|
||
if (m.role !== 'tool' || !m.type?.startsWith('tool_')) continue;
|
||
|
||
const key = m.toolCallId || m.id;
|
||
let entry = map.get(key);
|
||
|
||
if (!entry) {
|
||
entry = {
|
||
toolCallId: key,
|
||
toolName: m.toolName || 'Unknown',
|
||
status: 'calling',
|
||
arguments: undefined,
|
||
resultContent: '',
|
||
callContent: '',
|
||
};
|
||
map.set(key, entry);
|
||
}
|
||
|
||
if (m.type === 'tool_call') {
|
||
entry.arguments = m.arguments;
|
||
entry.callContent = m.content;
|
||
} else if (m.type === 'tool_result') {
|
||
entry.status = 'result';
|
||
entry.resultContent = m.content;
|
||
entry.durationMs = m.durationMs;
|
||
} else if (m.type === 'tool_pending') {
|
||
entry.status = 'pending';
|
||
entry.resultContent = m.content;
|
||
}
|
||
}
|
||
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
export function ToolPanel({ messages }: ToolPanelProps) {
|
||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||
const [detailModalTool, setDetailModalTool] = useState<ToolCallItem | null>(null);
|
||
|
||
const toolCalls = useMemo(() => mergeToolMessages(messages), [messages]);
|
||
|
||
const toggleExpand = (id: string) => {
|
||
setExpandedTools((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) {
|
||
next.delete(id);
|
||
} else {
|
||
next.add(id);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const getStatusConfig = (status: ToolCallItem['status']) => {
|
||
switch (status) {
|
||
case 'calling':
|
||
return {
|
||
icon: Play,
|
||
iconColor: 'text-amber-400',
|
||
bgClass: 'bg-amber-400',
|
||
borderClass: 'border-amber-500/30',
|
||
label: '执行中',
|
||
labelClass: 'text-amber-400',
|
||
};
|
||
case 'result':
|
||
return {
|
||
icon: Check,
|
||
iconColor: 'text-emerald-400',
|
||
bgClass: 'bg-emerald-400',
|
||
borderClass: 'border-emerald-500/30',
|
||
label: '已完成',
|
||
labelClass: 'text-emerald-400',
|
||
};
|
||
case 'pending':
|
||
return {
|
||
icon: AlertTriangle,
|
||
iconColor: 'text-orange-400',
|
||
bgClass: 'bg-orange-400',
|
||
borderClass: 'border-orange-500/30',
|
||
label: '待确认',
|
||
labelClass: 'text-orange-400',
|
||
};
|
||
}
|
||
};
|
||
|
||
if (toolCalls.length === 0) {
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<style>{animStyles}</style>
|
||
<div className="border-b border-[var(--border-color)] p-4 font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||
<Terminal className="h-4 w-4 text-[var(--accent-cyan)]" />
|
||
工具调用
|
||
</div>
|
||
<div className="flex flex-1 items-center justify-center p-4 text-sm text-[var(--text-muted)]">
|
||
<div className="text-center">
|
||
<Terminal className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||
暂无工具调用
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="flex h-full flex-col">
|
||
<style>{animStyles}</style>
|
||
<div className="border-b border-[var(--border-color)] p-4 font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||
<Terminal className="h-4 w-4 text-[var(--accent-cyan)]" />
|
||
工具调用
|
||
<span className="ml-auto text-xs px-2 py-0.5 rounded-full bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]">
|
||
{toolCalls.length}
|
||
</span>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-3">
|
||
<div className="space-y-2">
|
||
{toolCalls.map((tool) => {
|
||
const config = getStatusConfig(tool.status);
|
||
const StatusIcon = config.icon;
|
||
const isExpanded = expandedTools.has(tool.toolCallId);
|
||
const hasResult = tool.resultContent.length > 0;
|
||
const displayContent = tool.resultContent || tool.callContent;
|
||
const formattedContent = formatResultText(displayContent);
|
||
const previewLines = displayContent.split('\n').slice(0, 2).join('\n');
|
||
const hasMore = displayContent.split('\n').length > 2 || displayContent.length > 200;
|
||
|
||
return (
|
||
<div
|
||
key={tool.toolCallId}
|
||
className={`rounded-xl border bg-[var(--bg-tertiary)]/50 text-sm overflow-hidden tool-card transition-colors duration-500 ${config.borderClass}`}
|
||
>
|
||
<button
|
||
onClick={() => toggleExpand(tool.toolCallId)}
|
||
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-[var(--overlay-hover)] transition-colors"
|
||
>
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span
|
||
className={`tool-status-icon ${tool.status === 'calling' && !hasResult ? 'animate-pulse' : ''}`}
|
||
>
|
||
<StatusIcon
|
||
className={`h-3.5 w-3.5 transition-colors duration-500 ${config.iconColor}`}
|
||
/>
|
||
</span>
|
||
<span className="font-medium text-[var(--text-secondary)] truncate">
|
||
{tool.toolName}
|
||
</span>
|
||
<span
|
||
className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}
|
||
>
|
||
{config.label}
|
||
</span>
|
||
{tool.status === 'result' && tool.durationMs != null && (
|
||
<span className="text-xs text-[var(--text-muted)] flex-shrink-0 tabular-nums ml-1">
|
||
⏱️ {formatDuration(tool.durationMs)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setDetailModalTool(tool);
|
||
}}
|
||
className="p-0.5 rounded hover:bg-[var(--overlay-subtle)] transition-colors"
|
||
title="放大查看"
|
||
>
|
||
<Maximize2 className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||
</button>
|
||
{isExpanded ? (
|
||
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4 text-[var(--text-muted)]" />
|
||
)}
|
||
</div>
|
||
</button>
|
||
|
||
{/* 结果预览区 — 始终可见 */}
|
||
{hasResult && (
|
||
<div className="px-3 pb-2">
|
||
<div
|
||
className={`rounded-lg bg-[var(--overlay-dim-strong)] px-2.5 py-2 text-xs text-[var(--text-secondary)] font-mono cursor-pointer hover:bg-[var(--overlay-dim-heavy)] transition-colors ${
|
||
isExpanded ? '' : 'line-clamp-2'
|
||
}`}
|
||
onClick={() => toggleExpand(tool.toolCallId)}
|
||
>
|
||
{isExpanded ? (
|
||
<pre className="whitespace-pre-wrap break-all m-0">
|
||
{formattedContent}
|
||
</pre>
|
||
) : (
|
||
<span className="whitespace-pre-wrap break-all">{previewLines}</span>
|
||
)}
|
||
</div>
|
||
{!isExpanded && hasMore && (
|
||
<div className="text-xs text-[var(--text-muted)] mt-1 px-1">
|
||
点击展开全部 ({displayContent.split('\n').length} 行)
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 展开区域:参数 */}
|
||
{isExpanded && tool.arguments ? (
|
||
<div className="border-t border-[var(--border-color)] px-3 py-2 bg-[var(--overlay-dim)]">
|
||
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">参数:</div>
|
||
<pre className="rounded-lg bg-[var(--overlay-dim-heavy)] p-2 text-xs overflow-x-auto text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-all">
|
||
{JSON.stringify(tool.arguments, null, 2)}
|
||
</pre>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{detailModalTool && (
|
||
<ToolDetailModal
|
||
toolName={detailModalTool.toolName}
|
||
status={detailModalTool.status}
|
||
statusLabel={
|
||
detailModalTool.status === 'calling'
|
||
? '执行中'
|
||
: detailModalTool.status === 'result'
|
||
? '已完成'
|
||
: detailModalTool.status === 'pending'
|
||
? '待确认'
|
||
: detailModalTool.status
|
||
}
|
||
arguments={detailModalTool.arguments}
|
||
resultContent={detailModalTool.resultContent}
|
||
callContent={detailModalTool.callContent}
|
||
durationMs={detailModalTool.durationMs}
|
||
onClose={() => setDetailModalTool(null)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const animStyles = `
|
||
@keyframes tool-result-in {
|
||
from { max-height: 0; opacity: 0; }
|
||
to { max-height: 80px; opacity: 1; }
|
||
}
|
||
|
||
.tool-card {
|
||
transition: border-color 0.5s ease;
|
||
}
|
||
|
||
.tool-status-icon {
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.tool-result-enter {
|
||
animation: tool-result-in 0.4s ease-out;
|
||
}
|
||
|
||
.line-clamp-2 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
`;
|