|
1 | 1 | /** |
2 | 2 | * 工具调用日志内容组件 |
3 | | - * 用于在 BottomBarPopover 中显示 |
| 3 | + * 包含日志列表和统计视图 |
4 | 4 | */ |
5 | 5 |
|
6 | | -import { useState } from 'react' |
7 | | -import { Trash2, Download, Copy, Check, ChevronDown, ChevronRight } from 'lucide-react' |
| 6 | +import { useState, useMemo } from 'react' |
| 7 | +import { |
| 8 | + Trash2, |
| 9 | + Download, |
| 10 | + Copy, |
| 11 | + Check, |
| 12 | + ChevronDown, |
| 13 | + ChevronRight, |
| 14 | + BarChart3, |
| 15 | + List, |
| 16 | + AlertTriangle, |
| 17 | + Clock, |
| 18 | + Zap, |
| 19 | +} from 'lucide-react' |
8 | 20 | import { Button } from '../ui' |
9 | 21 | import { JsonHighlight } from '@/renderer/utils/jsonHighlight' |
10 | 22 | import { useStore } from '@/renderer/store' |
11 | 23 |
|
12 | 24 | interface ToolCallLogContentProps { |
13 | | - language?: 'en' | 'zh' |
| 25 | + language?: 'en' | 'zh' |
14 | 26 | } |
15 | 27 |
|
| 28 | +type ViewMode = 'logs' | 'stats' |
| 29 | + |
16 | 30 | export default function ToolCallLogContent({ language = 'zh' }: ToolCallLogContentProps) { |
17 | | - const { toolCallLogs: logs, clearToolCallLogs } = useStore() |
18 | | - const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()) |
19 | | - const [filter, setFilter] = useState<'all' | 'request' | 'response'>('all') |
20 | | - const [copiedId, setCopiedId] = useState<string | null>(null) |
21 | | - |
22 | | - const toggleExpand = (id: string) => { |
23 | | - const newExpanded = new Set(expandedIds) |
24 | | - if (newExpanded.has(id)) { |
25 | | - newExpanded.delete(id) |
26 | | - } else { |
27 | | - newExpanded.add(id) |
28 | | - } |
29 | | - setExpandedIds(newExpanded) |
30 | | - } |
| 31 | + const { toolCallLogs: logs, clearToolCallLogs, getToolStats, getPerformanceInsights } = useStore() |
| 32 | + const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()) |
| 33 | + const [filter, setFilter] = useState<'all' | 'request' | 'response'>('all') |
| 34 | + const [copiedId, setCopiedId] = useState<string | null>(null) |
| 35 | + const [viewMode, setViewMode] = useState<ViewMode>('logs') |
31 | 36 |
|
32 | | - const handleCopy = async (id: string, data: unknown) => { |
33 | | - await navigator.clipboard.writeText(JSON.stringify(data, null, 2)) |
34 | | - setCopiedId(id) |
35 | | - setTimeout(() => setCopiedId(null), 2000) |
36 | | - } |
| 37 | + const stats = useMemo(() => getToolStats(), [logs]) |
| 38 | + const insights = useMemo(() => getPerformanceInsights(), [logs]) |
37 | 39 |
|
38 | | - const handleExport = () => { |
39 | | - const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' }) |
40 | | - const url = URL.createObjectURL(blob) |
41 | | - const a = document.createElement('a') |
42 | | - a.href = url |
43 | | - a.download = `tool-logs-${new Date().toISOString().slice(0, 10)}.json` |
44 | | - a.click() |
45 | | - URL.revokeObjectURL(url) |
| 40 | + const toggleExpand = (id: string) => { |
| 41 | + const newExpanded = new Set(expandedIds) |
| 42 | + if (newExpanded.has(id)) { |
| 43 | + newExpanded.delete(id) |
| 44 | + } else { |
| 45 | + newExpanded.add(id) |
46 | 46 | } |
| 47 | + setExpandedIds(newExpanded) |
| 48 | + } |
47 | 49 |
|
48 | | - const filteredLogs = filter === 'all' ? logs : logs.filter(log => log.type === filter) |
49 | | - |
50 | | - return ( |
51 | | - <div className="h-full flex flex-col"> |
52 | | - {/* 工具栏 */} |
53 | | - <div className="flex items-center gap-2 px-2 py-1.5 border-b border-border-subtle bg-surface/30"> |
54 | | - <select |
55 | | - value={filter} |
56 | | - onChange={e => setFilter(e.target.value as 'all' | 'request' | 'response')} |
57 | | - className="px-1.5 py-0.5 text-[10px] bg-surface border border-border-subtle rounded text-text-secondary outline-none focus:border-accent/50" |
58 | | - > |
59 | | - <option value="all">{language === 'zh' ? '全部' : 'All'}</option> |
60 | | - <option value="request">{language === 'zh' ? '请求' : 'Req'}</option> |
61 | | - <option value="response">{language === 'zh' ? '响应' : 'Res'}</option> |
62 | | - </select> |
63 | | - <div className="flex-1" /> |
64 | | - <Button |
65 | | - variant="ghost" |
66 | | - size="sm" |
67 | | - onClick={handleExport} |
68 | | - className="h-6 px-1.5 text-[10px] gap-1 text-text-muted hover:text-text-primary" |
69 | | - title={language === 'zh' ? '导出日志' : 'Export Logs'} |
70 | | - > |
71 | | - <Download className="w-3 h-3" /> |
72 | | - <span className="hidden sm:inline">{language === 'zh' ? '导出' : 'Export'}</span> |
73 | | - </Button> |
74 | | - <Button |
75 | | - variant="ghost" |
76 | | - size="sm" |
77 | | - onClick={clearToolCallLogs} |
78 | | - className="h-6 px-1.5 text-[10px] gap-1 text-text-muted hover:text-red-400 hover:bg-red-500/10" |
79 | | - title={language === 'zh' ? '清除日志' : 'Clear Logs'} |
80 | | - > |
81 | | - <Trash2 className="w-3 h-3" /> |
82 | | - <span className="hidden sm:inline">{language === 'zh' ? '清除' : 'Clear'}</span> |
83 | | - </Button> |
84 | | - </div> |
| 50 | + const handleCopy = async (id: string, data: unknown) => { |
| 51 | + await navigator.clipboard.writeText(JSON.stringify(data, null, 2)) |
| 52 | + setCopiedId(id) |
| 53 | + setTimeout(() => setCopiedId(null), 2000) |
| 54 | + } |
| 55 | + |
| 56 | + const handleExport = () => { |
| 57 | + const exportData = { logs, stats, insights, exportedAt: new Date().toISOString() } |
| 58 | + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) |
| 59 | + const url = URL.createObjectURL(blob) |
| 60 | + const a = document.createElement('a') |
| 61 | + a.href = url |
| 62 | + a.download = `tool-logs-${new Date().toISOString().slice(0, 10)}.json` |
| 63 | + a.click() |
| 64 | + URL.revokeObjectURL(url) |
| 65 | + } |
| 66 | + |
| 67 | + const filteredLogs = filter === 'all' ? logs : logs.filter((log) => log.type === filter) |
| 68 | + const t = (zh: string, en: string) => (language === 'zh' ? zh : en) |
| 69 | + |
| 70 | + |
| 71 | + return ( |
| 72 | + <div className="h-full flex flex-col"> |
| 73 | + {/* 工具栏 */} |
| 74 | + <div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border-subtle bg-surface/30"> |
| 75 | + {/* 视图切换 */} |
| 76 | + <div className="flex bg-surface/50 rounded p-0.5"> |
| 77 | + <button |
| 78 | + onClick={() => setViewMode('logs')} |
| 79 | + className={`px-1.5 py-0.5 text-[10px] rounded transition-colors ${ |
| 80 | + viewMode === 'logs' ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-primary' |
| 81 | + }`} |
| 82 | + title={t('日志', 'Logs')} |
| 83 | + > |
| 84 | + <List className="w-3 h-3" /> |
| 85 | + </button> |
| 86 | + <button |
| 87 | + onClick={() => setViewMode('stats')} |
| 88 | + className={`px-1.5 py-0.5 text-[10px] rounded transition-colors ${ |
| 89 | + viewMode === 'stats' ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-primary' |
| 90 | + }`} |
| 91 | + title={t('统计', 'Stats')} |
| 92 | + > |
| 93 | + <BarChart3 className="w-3 h-3" /> |
| 94 | + </button> |
| 95 | + </div> |
| 96 | + |
| 97 | + {viewMode === 'logs' && ( |
| 98 | + <select |
| 99 | + value={filter} |
| 100 | + onChange={(e) => setFilter(e.target.value as 'all' | 'request' | 'response')} |
| 101 | + className="px-1.5 py-0.5 text-[10px] bg-surface border border-border-subtle rounded text-text-secondary outline-none focus:border-accent/50" |
| 102 | + > |
| 103 | + <option value="all">{t('全部', 'All')}</option> |
| 104 | + <option value="request">{t('请求', 'Req')}</option> |
| 105 | + <option value="response">{t('响应', 'Res')}</option> |
| 106 | + </select> |
| 107 | + )} |
| 108 | + |
| 109 | + <div className="flex-1" /> |
| 110 | + |
| 111 | + <Button variant="ghost" size="sm" onClick={handleExport} |
| 112 | + className="h-6 px-1.5 text-[10px] gap-1 text-text-muted hover:text-text-primary" title={t('导出', 'Export')}> |
| 113 | + <Download className="w-3 h-3" /> |
| 114 | + </Button> |
| 115 | + <Button variant="ghost" size="sm" onClick={clearToolCallLogs} |
| 116 | + className="h-6 px-1.5 text-[10px] gap-1 text-text-muted hover:text-red-400 hover:bg-red-500/10" title={t('清除', 'Clear')}> |
| 117 | + <Trash2 className="w-3 h-3" /> |
| 118 | + </Button> |
| 119 | + </div> |
| 120 | + |
| 121 | + {/* 内容区域 */} |
| 122 | + <div className="flex-1 overflow-auto"> |
| 123 | + {viewMode === 'logs' ? ( |
| 124 | + <LogsView logs={filteredLogs} expandedIds={expandedIds} toggleExpand={toggleExpand} |
| 125 | + handleCopy={handleCopy} copiedId={copiedId} language={language} /> |
| 126 | + ) : ( |
| 127 | + <StatsView stats={stats} insights={insights} language={language} /> |
| 128 | + )} |
| 129 | + </div> |
| 130 | + </div> |
| 131 | + ) |
| 132 | +} |
| 133 | + |
| 134 | + |
| 135 | +// 日志列表视图 |
| 136 | +function LogsView({ logs, expandedIds, toggleExpand, handleCopy, copiedId, language }: { |
| 137 | + logs: import('@/renderer/store/slices/logSlice').ToolCallLogEntry[] |
| 138 | + expandedIds: Set<string> |
| 139 | + toggleExpand: (id: string) => void |
| 140 | + handleCopy: (id: string, data: unknown) => void |
| 141 | + copiedId: string | null |
| 142 | + language?: string |
| 143 | +}) { |
| 144 | + const t = (zh: string, en: string) => (language === 'zh' ? zh : en) |
| 145 | + |
| 146 | + if (logs.length === 0) { |
| 147 | + return <div className="flex items-center justify-center h-full text-text-muted text-xs">{t('暂无日志', 'No logs')}</div> |
| 148 | + } |
85 | 149 |
|
86 | | - {/* 日志列表 */} |
87 | | - <div className="flex-1 overflow-auto"> |
88 | | - {filteredLogs.length === 0 ? ( |
89 | | - <div className="flex items-center justify-center h-full text-text-muted text-xs"> |
90 | | - {language === 'zh' ? '暂无日志' : 'No logs'} |
91 | | - </div> |
92 | | - ) : ( |
93 | | - <div className="divide-y divide-border-subtle"> |
94 | | - {filteredLogs.map(log => ( |
95 | | - <div key={log.id}> |
96 | | - <button |
97 | | - onClick={() => toggleExpand(log.id)} |
98 | | - className="w-full flex items-center gap-1.5 px-2 py-1.5 hover:bg-surface/50 text-left" |
99 | | - > |
100 | | - {expandedIds.has(log.id) |
101 | | - ? <ChevronDown className="w-3 h-3 text-text-muted" /> |
102 | | - : <ChevronRight className="w-3 h-3 text-text-muted" /> |
103 | | - } |
104 | | - <span className={`px-1 py-0.5 text-[9px] rounded font-medium ${log.type === 'request' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400' |
105 | | - }`}> |
106 | | - {log.type === 'request' ? 'REQ' : 'RES'} |
107 | | - </span> |
108 | | - <span className="text-[10px] font-medium text-text-primary truncate flex-1">{log.toolName}</span> |
109 | | - {log.duration && <span className="text-[9px] text-text-muted">{log.duration}ms</span>} |
110 | | - </button> |
111 | | - |
112 | | - {expandedIds.has(log.id) && ( |
113 | | - <div className="relative px-2 pb-2"> |
114 | | - <button |
115 | | - onClick={() => handleCopy(log.id, log.data)} |
116 | | - className="absolute top-1 right-2 p-0.5 hover:bg-surface rounded" |
117 | | - > |
118 | | - {copiedId === log.id |
119 | | - ? <Check className="w-3 h-3 text-green-400" /> |
120 | | - : <Copy className="w-3 h-3 text-text-muted" /> |
121 | | - } |
122 | | - </button> |
123 | | - <div className="bg-surface/50 rounded p-1.5 overflow-auto max-h-32"> |
124 | | - <JsonHighlight data={log.data} maxHeight="max-h-28" /> |
125 | | - </div> |
126 | | - </div> |
127 | | - )} |
128 | | - </div> |
129 | | - ))} |
130 | | - </div> |
131 | | - )} |
| 150 | + return ( |
| 151 | + <div className="divide-y divide-border-subtle"> |
| 152 | + {logs.map((log) => ( |
| 153 | + <div key={log.id}> |
| 154 | + <button onClick={() => toggleExpand(log.id)} |
| 155 | + className="w-full flex items-center gap-1.5 px-2 py-1.5 hover:bg-surface/50 text-left"> |
| 156 | + {expandedIds.has(log.id) ? <ChevronDown className="w-3 h-3 text-text-muted" /> : <ChevronRight className="w-3 h-3 text-text-muted" />} |
| 157 | + <span className={`px-1 py-0.5 text-[9px] rounded font-medium ${log.type === 'request' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'}`}> |
| 158 | + {log.type === 'request' ? 'REQ' : 'RES'} |
| 159 | + </span> |
| 160 | + <span className="text-[10px] font-medium text-text-primary truncate flex-1">{log.toolName}</span> |
| 161 | + {log.success === false && <AlertTriangle className="w-3 h-3 text-red-400" />} |
| 162 | + {log.duration && <span className="text-[9px] text-text-muted">{log.duration}ms</span>} |
| 163 | + </button> |
| 164 | + |
| 165 | + {expandedIds.has(log.id) && ( |
| 166 | + <div className="relative px-2 pb-2"> |
| 167 | + <button onClick={() => handleCopy(log.id, log.data)} className="absolute top-1 right-2 p-0.5 hover:bg-surface rounded"> |
| 168 | + {copiedId === log.id ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3 text-text-muted" />} |
| 169 | + </button> |
| 170 | + <div className="bg-surface/50 rounded p-1.5 overflow-auto max-h-32"> |
| 171 | + <JsonHighlight data={log.data} maxHeight="max-h-28" /> |
| 172 | + </div> |
132 | 173 | </div> |
| 174 | + )} |
| 175 | + </div> |
| 176 | + ))} |
| 177 | + </div> |
| 178 | + ) |
| 179 | +} |
| 180 | + |
| 181 | + |
| 182 | +// 统计视图 |
| 183 | +function StatsView({ stats, insights, language }: { |
| 184 | + stats: import('@/renderer/store/slices/logSlice').ToolStats[] |
| 185 | + insights: import('@/renderer/store/slices/logSlice').PerformanceInsight[] |
| 186 | + language?: string |
| 187 | +}) { |
| 188 | + const t = (zh: string, en: string) => (language === 'zh' ? zh : en) |
| 189 | + |
| 190 | + if (stats.length === 0) { |
| 191 | + return <div className="flex items-center justify-center h-full text-text-muted text-xs">{t('暂无统计数据', 'No statistics')}</div> |
| 192 | + } |
| 193 | + |
| 194 | + return ( |
| 195 | + <div className="p-2 space-y-3"> |
| 196 | + {/* 性能洞察 */} |
| 197 | + {insights.length > 0 && ( |
| 198 | + <div className="space-y-1"> |
| 199 | + <div className="text-[10px] font-medium text-text-muted uppercase tracking-wide">{t('性能洞察', 'Insights')}</div> |
| 200 | + <div className="space-y-1"> |
| 201 | + {insights.slice(0, 3).map((insight, i) => ( |
| 202 | + <div key={i} className={`flex items-center gap-2 px-2 py-1 rounded text-[10px] ${ |
| 203 | + insight.severity === 'critical' ? 'bg-red-500/10 text-red-400' : |
| 204 | + insight.severity === 'warning' ? 'bg-yellow-500/10 text-yellow-400' : 'bg-blue-500/10 text-blue-400' |
| 205 | + }`}> |
| 206 | + {insight.type === 'slow_tool' && <Clock className="w-3 h-3" />} |
| 207 | + {insight.type === 'high_failure' && <AlertTriangle className="w-3 h-3" />} |
| 208 | + {insight.type === 'frequent_tool' && <Zap className="w-3 h-3" />} |
| 209 | + <span className="font-medium">{insight.toolName}</span> |
| 210 | + <span className="text-[9px] opacity-80">{language === 'zh' ? insight.messageZh : insight.message}</span> |
| 211 | + </div> |
| 212 | + ))} |
| 213 | + </div> |
| 214 | + </div> |
| 215 | + )} |
| 216 | + |
| 217 | + {/* 工具统计表 */} |
| 218 | + <div className="space-y-1"> |
| 219 | + <div className="text-[10px] font-medium text-text-muted uppercase tracking-wide">{t('工具统计', 'Tool Stats')}</div> |
| 220 | + <div className="bg-surface/30 rounded border border-border-subtle overflow-hidden"> |
| 221 | + <table className="w-full text-[10px]"> |
| 222 | + <thead> |
| 223 | + <tr className="bg-surface/50 text-text-muted"> |
| 224 | + <th className="text-left px-2 py-1 font-medium">{t('工具', 'Tool')}</th> |
| 225 | + <th className="text-right px-2 py-1 font-medium">{t('调用', 'Calls')}</th> |
| 226 | + <th className="text-right px-2 py-1 font-medium">{t('成功率', 'Rate')}</th> |
| 227 | + <th className="text-right px-2 py-1 font-medium">{t('平均', 'Avg')}</th> |
| 228 | + </tr> |
| 229 | + </thead> |
| 230 | + <tbody className="divide-y divide-border-subtle"> |
| 231 | + {stats.slice(0, 8).map((stat) => ( |
| 232 | + <tr key={stat.toolName} className="hover:bg-surface/30"> |
| 233 | + <td className="px-2 py-1 text-text-primary truncate max-w-[100px]" title={stat.toolName}>{stat.toolName}</td> |
| 234 | + <td className="px-2 py-1 text-right text-text-secondary">{stat.totalCalls}</td> |
| 235 | + <td className="px-2 py-1 text-right"> |
| 236 | + <span className={stat.successRate >= 0.9 ? 'text-green-400' : stat.successRate >= 0.7 ? 'text-yellow-400' : 'text-red-400'}> |
| 237 | + {Math.round(stat.successRate * 100)}% |
| 238 | + </span> |
| 239 | + </td> |
| 240 | + <td className="px-2 py-1 text-right text-text-muted">{Math.round(stat.avgDuration)}ms</td> |
| 241 | + </tr> |
| 242 | + ))} |
| 243 | + </tbody> |
| 244 | + </table> |
133 | 245 | </div> |
134 | | - ) |
| 246 | + </div> |
| 247 | + </div> |
| 248 | + ) |
135 | 249 | } |
0 commit comments