PicoBot/web/src/components/Panel/ToolPanel.tsx
oudecheng c724bbf864 chore(web): prettier 一次性格式化并在 CI 启用 format:check
对 47 个前端文件统一执行 npm run format,消除存量格式差异。
随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。
2026-08-03 23:25:22 +08:00

317 lines
11 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.

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;
}
`;