@benzsiangco/jarvis 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +478 -347
- package/dist/electron/main.js +160 -0
- package/dist/electron/preload.js +19 -0
- package/package.json +19 -6
- package/skills.md +147 -0
- package/src/agents/index.ts +248 -0
- package/src/brain/loader.ts +136 -0
- package/src/cli.ts +411 -0
- package/src/config/index.ts +363 -0
- package/src/core/executor.ts +222 -0
- package/src/core/plugins.ts +148 -0
- package/src/core/types.ts +217 -0
- package/src/electron/main.ts +192 -0
- package/src/electron/preload.ts +25 -0
- package/src/electron/types.d.ts +20 -0
- package/src/index.ts +12 -0
- package/src/providers/antigravity-loader.ts +233 -0
- package/src/providers/antigravity.ts +585 -0
- package/src/providers/index.ts +523 -0
- package/src/sessions/index.ts +194 -0
- package/src/tools/index.ts +436 -0
- package/src/tui/index.tsx +784 -0
- package/src/utils/auth-prompt.ts +394 -0
- package/src/utils/index.ts +180 -0
- package/src/utils/native-picker.ts +71 -0
- package/src/utils/skills.ts +99 -0
- package/src/utils/table-integration-examples.ts +617 -0
- package/src/utils/table-utils.ts +401 -0
- package/src/web/build-ui.ts +27 -0
- package/src/web/server.ts +674 -0
- package/src/web/ui/dist/.gitkeep +0 -0
- package/src/web/ui/dist/main.css +1 -0
- package/src/web/ui/dist/main.js +320 -0
- package/src/web/ui/dist/main.js.map +20 -0
- package/src/web/ui/index.html +46 -0
- package/src/web/ui/src/App.tsx +143 -0
- package/src/web/ui/src/Modules/Safety/GuardianModal.tsx +83 -0
- package/src/web/ui/src/components/Layout/ContextPanel.tsx +243 -0
- package/src/web/ui/src/components/Layout/Header.tsx +91 -0
- package/src/web/ui/src/components/Layout/ModelSelector.tsx +235 -0
- package/src/web/ui/src/components/Layout/SessionStats.tsx +369 -0
- package/src/web/ui/src/components/Layout/Sidebar.tsx +895 -0
- package/src/web/ui/src/components/Modules/Chat/ChatStage.tsx +620 -0
- package/src/web/ui/src/components/Modules/Chat/MessageItem.tsx +446 -0
- package/src/web/ui/src/components/Modules/Editor/CommandInspector.tsx +71 -0
- package/src/web/ui/src/components/Modules/Editor/DiffViewer.tsx +83 -0
- package/src/web/ui/src/components/Modules/Terminal/TabbedTerminal.tsx +202 -0
- package/src/web/ui/src/components/Settings/SettingsModal.tsx +935 -0
- package/src/web/ui/src/config/models.ts +70 -0
- package/src/web/ui/src/main.tsx +13 -0
- package/src/web/ui/src/store/agentStore.ts +41 -0
- package/src/web/ui/src/store/uiStore.ts +64 -0
- package/src/web/ui/src/types/index.ts +54 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { ChevronDown, Sparkles, Loader2, AlertTriangle } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface Model {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
isAvailable?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ModelSelectorProps {
|
|
13
|
+
selectedModelId: string;
|
|
14
|
+
onModelChange: (modelId: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ModelSelector({ selectedModelId, onModelChange }: ModelSelectorProps) {
|
|
18
|
+
const [models, setModels] = useState<Model[]>([]);
|
|
19
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
20
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
23
|
+
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
|
24
|
+
|
|
25
|
+
// Fetch models from API
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const fetchModels = async () => {
|
|
28
|
+
try {
|
|
29
|
+
setIsLoading(true);
|
|
30
|
+
setError(null);
|
|
31
|
+
|
|
32
|
+
const response = await fetch('/api/models');
|
|
33
|
+
if (!response.ok) throw new Error('Failed to fetch models');
|
|
34
|
+
|
|
35
|
+
const data = await response.json();
|
|
36
|
+
|
|
37
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
38
|
+
setModels(data);
|
|
39
|
+
|
|
40
|
+
// Auto-select first available model if none selected
|
|
41
|
+
if (!selectedModelId) {
|
|
42
|
+
const firstAvailable = data.find((m: Model) => m.isAvailable) || data[0];
|
|
43
|
+
if (firstAvailable) {
|
|
44
|
+
onModelChange(firstAvailable.id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error('No models available');
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('Model fetch error:', err);
|
|
52
|
+
setError(err instanceof Error ? err.message : 'Failed to load models');
|
|
53
|
+
|
|
54
|
+
// Fallback models
|
|
55
|
+
const fallback: Model[] = [
|
|
56
|
+
{ id: 'google/antigravity-claude-sonnet-4-5', name: 'Claude Sonnet 4.5', provider: 'google', isAvailable: true },
|
|
57
|
+
{ id: 'google/antigravity-gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', isAvailable: true },
|
|
58
|
+
];
|
|
59
|
+
setModels(fallback);
|
|
60
|
+
|
|
61
|
+
if (!selectedModelId && fallback[0]) {
|
|
62
|
+
onModelChange(fallback[0].id);
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
setIsLoading(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
fetchModels();
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// Click outside to close
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!isOpen) return;
|
|
75
|
+
|
|
76
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
77
|
+
const target = e.target as HTMLElement;
|
|
78
|
+
// Check if click is outside both button and dropdown
|
|
79
|
+
if (
|
|
80
|
+
buttonRef.current && !buttonRef.current.contains(target) &&
|
|
81
|
+
!target.closest('.model-selector-dropdown')
|
|
82
|
+
) {
|
|
83
|
+
setIsOpen(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
88
|
+
if (e.key === 'Escape') setIsOpen(false);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
92
|
+
document.addEventListener('keydown', handleEscape);
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
96
|
+
document.removeEventListener('keydown', handleEscape);
|
|
97
|
+
};
|
|
98
|
+
}, [isOpen]);
|
|
99
|
+
|
|
100
|
+
const selectedModel = models.find(m => m.id === selectedModelId);
|
|
101
|
+
|
|
102
|
+
const handleSelect = (modelId: string) => {
|
|
103
|
+
onModelChange(modelId);
|
|
104
|
+
setIsOpen(false);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleToggle = () => {
|
|
108
|
+
if (!isLoading && buttonRef.current) {
|
|
109
|
+
const rect = buttonRef.current.getBoundingClientRect();
|
|
110
|
+
setDropdownPosition({
|
|
111
|
+
top: rect.bottom + 8,
|
|
112
|
+
left: rect.left
|
|
113
|
+
});
|
|
114
|
+
setIsOpen(!isOpen);
|
|
115
|
+
console.log('Setting isOpen to:', !isOpen);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
console.log('ModelSelector render - isOpen:', isOpen, 'isLoading:', isLoading, 'models:', models.length);
|
|
120
|
+
|
|
121
|
+
const dropdownContent = isOpen && !isLoading && (
|
|
122
|
+
<>
|
|
123
|
+
<style>{`
|
|
124
|
+
.model-dropdown-scroll::-webkit-scrollbar {
|
|
125
|
+
display: none;
|
|
126
|
+
}
|
|
127
|
+
`}</style>
|
|
128
|
+
<div
|
|
129
|
+
className="model-selector-dropdown fixed w-80 bg-zinc-900 border-2 border-cyan-500 rounded-lg shadow-2xl z-[9999] overflow-hidden"
|
|
130
|
+
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
|
|
131
|
+
>
|
|
132
|
+
{error && (
|
|
133
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-amber-500/10 border-b border-amber-500/20">
|
|
134
|
+
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
|
|
135
|
+
<span className="text-xs text-amber-400">{error}</span>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
<div
|
|
140
|
+
className="max-h-[400px] overflow-y-auto model-dropdown-scroll"
|
|
141
|
+
style={{
|
|
142
|
+
scrollbarWidth: 'none', /* Firefox */
|
|
143
|
+
msOverflowStyle: 'none', /* IE and Edge */
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{models.length === 0 ? (
|
|
147
|
+
<div className="px-4 py-8 text-center text-zinc-500 text-sm">
|
|
148
|
+
No models available
|
|
149
|
+
</div>
|
|
150
|
+
) : (
|
|
151
|
+
<div className="p-2 space-y-1">
|
|
152
|
+
{models.map((model) => {
|
|
153
|
+
const isSelected = model.id === selectedModelId;
|
|
154
|
+
const isDisabled = model.isAvailable === false;
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<button
|
|
158
|
+
key={model.id}
|
|
159
|
+
onClick={() => !isDisabled && handleSelect(model.id)}
|
|
160
|
+
disabled={isDisabled}
|
|
161
|
+
className={`
|
|
162
|
+
w-full px-3 py-2.5 rounded-md text-left transition-all
|
|
163
|
+
${isSelected
|
|
164
|
+
? 'bg-cyan-500/20 border border-cyan-500/30'
|
|
165
|
+
: 'hover:bg-zinc-800/50 border border-transparent'
|
|
166
|
+
}
|
|
167
|
+
${isDisabled
|
|
168
|
+
? 'opacity-40 cursor-not-allowed'
|
|
169
|
+
: 'cursor-pointer'
|
|
170
|
+
}
|
|
171
|
+
`}
|
|
172
|
+
>
|
|
173
|
+
<div className="flex items-start justify-between gap-3">
|
|
174
|
+
<div className="flex-1 min-w-0">
|
|
175
|
+
<div className="flex items-center gap-2">
|
|
176
|
+
<h4 className={`text-sm font-semibold truncate ${isSelected ? 'text-cyan-300' : 'text-zinc-200'}`}>
|
|
177
|
+
{model.name}
|
|
178
|
+
</h4>
|
|
179
|
+
{isSelected && (
|
|
180
|
+
<div className="w-2 h-2 rounded-full bg-cyan-500 shrink-0" />
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
184
|
+
<p className="text-xs text-zinc-500 uppercase tracking-wide">
|
|
185
|
+
{model.provider}
|
|
186
|
+
</p>
|
|
187
|
+
{isDisabled && (
|
|
188
|
+
<span className="text-[10px] px-1.5 py-0.5 bg-zinc-800 text-zinc-500 rounded uppercase tracking-wide">
|
|
189
|
+
Key Missing
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</button>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</>
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div className="relative">
|
|
207
|
+
{/* Trigger Button */}
|
|
208
|
+
<button
|
|
209
|
+
ref={buttonRef}
|
|
210
|
+
onClick={handleToggle}
|
|
211
|
+
disabled={isLoading}
|
|
212
|
+
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-900/50 hover:bg-zinc-900 border border-zinc-800 hover:border-zinc-700 rounded-lg transition-all group disabled:opacity-50 disabled:cursor-not-allowed"
|
|
213
|
+
>
|
|
214
|
+
<div className="flex items-center justify-center w-5 h-5 rounded bg-gradient-to-br from-cyan-500/20 to-blue-500/20 group-hover:from-cyan-500/30 group-hover:to-blue-500/30 transition-all">
|
|
215
|
+
{isLoading ? (
|
|
216
|
+
<Loader2 className="w-3 h-3 text-cyan-400 animate-spin" />
|
|
217
|
+
) : (
|
|
218
|
+
<Sparkles className="w-3 h-3 text-cyan-400" />
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<span className="text-sm font-semibold text-zinc-300 max-w-[120px] md:max-w-[200px] truncate">
|
|
223
|
+
{isLoading ? 'Loading...' : selectedModel?.name || 'Select Model'}
|
|
224
|
+
</span>
|
|
225
|
+
|
|
226
|
+
<ChevronDown
|
|
227
|
+
className={`w-4 h-4 text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
228
|
+
/>
|
|
229
|
+
</button>
|
|
230
|
+
|
|
231
|
+
{/* Dropdown Menu - Rendered via Portal */}
|
|
232
|
+
{typeof document !== 'undefined' && createPortal(dropdownContent, document.body)}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import type { Session } from '../../types';
|
|
3
|
+
import { Copy, CheckCircle2 } from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
MODEL_CONTEXT_LIMITS,
|
|
6
|
+
MODEL_PRICING,
|
|
7
|
+
MODEL_DISPLAY_NAMES,
|
|
8
|
+
PROVIDER_NAMES
|
|
9
|
+
} from '../../config/models';
|
|
10
|
+
|
|
11
|
+
interface SessionStatsData {
|
|
12
|
+
totalTokens: number;
|
|
13
|
+
inputTokens: number;
|
|
14
|
+
outputTokens: number;
|
|
15
|
+
reasoningTokens: number;
|
|
16
|
+
cacheTokensRead: number;
|
|
17
|
+
cacheTokensWrite: number;
|
|
18
|
+
userMessages: number;
|
|
19
|
+
assistantMessages: number;
|
|
20
|
+
modelId: string;
|
|
21
|
+
modelName: string;
|
|
22
|
+
providerId: string;
|
|
23
|
+
contextLimit: number | null;
|
|
24
|
+
totalCost: number;
|
|
25
|
+
usagePercent: number | null;
|
|
26
|
+
contextBreakdown: {
|
|
27
|
+
user: number;
|
|
28
|
+
assistant: number;
|
|
29
|
+
toolCalls: number;
|
|
30
|
+
other: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function SessionStats({ sessionId }: { sessionId: string | null }) {
|
|
35
|
+
const [session, setSession] = useState<Session | null>(null);
|
|
36
|
+
const [stats, setStats] = useState<SessionStatsData | null>(null);
|
|
37
|
+
const [loading, setLoading] = useState(true);
|
|
38
|
+
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
// Fetch session data
|
|
41
|
+
const fetchSession = async () => {
|
|
42
|
+
if (!sessionId) {
|
|
43
|
+
setSession(null);
|
|
44
|
+
setStats(null);
|
|
45
|
+
setLoading(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
51
|
+
if (!res.ok) throw new Error('Failed to fetch session');
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
setSession(data);
|
|
54
|
+
setStats(calculateStats(data));
|
|
55
|
+
setLoading(false);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error('Failed to fetch session:', err);
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Initial fetch
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
fetchSession();
|
|
65
|
+
}, [sessionId]);
|
|
66
|
+
|
|
67
|
+
// Real-time polling every 2 seconds
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!sessionId) return;
|
|
70
|
+
const interval = setInterval(fetchSession, 2000);
|
|
71
|
+
return () => clearInterval(interval);
|
|
72
|
+
}, [sessionId]);
|
|
73
|
+
|
|
74
|
+
// Copy message ID to clipboard
|
|
75
|
+
const copyMessageId = (id: string) => {
|
|
76
|
+
navigator.clipboard.writeText(id);
|
|
77
|
+
setCopiedId(id);
|
|
78
|
+
setTimeout(() => setCopiedId(null), 2000);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Empty state
|
|
82
|
+
if (!sessionId) {
|
|
83
|
+
return (
|
|
84
|
+
<div className="flex items-center justify-center h-64 text-zinc-500">
|
|
85
|
+
<p className="text-sm">No session selected</p>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Loading state
|
|
91
|
+
if (loading) {
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex items-center justify-center h-64">
|
|
94
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-500" />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Error state
|
|
100
|
+
if (!session || !stats) {
|
|
101
|
+
return (
|
|
102
|
+
<div className="flex items-center justify-center h-64 text-red-500">
|
|
103
|
+
<p className="text-sm">Failed to load session stats</p>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="space-y-6">
|
|
110
|
+
{/* Stats Grid - Two Column Layout */}
|
|
111
|
+
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
112
|
+
<StatRow label="Session" value={truncateId(session.id)} mono />
|
|
113
|
+
<StatRow label="Messages" value={session.messages.length} />
|
|
114
|
+
|
|
115
|
+
<StatRow label="Provider" value={stats.providerId} />
|
|
116
|
+
<StatRow label="Model" value={stats.modelName} />
|
|
117
|
+
|
|
118
|
+
<StatRow label="Context Limit" value={formatNumber(stats.contextLimit)} />
|
|
119
|
+
<StatRow label="Total Tokens" value={formatNumber(stats.totalTokens)} highlight="cyan" />
|
|
120
|
+
|
|
121
|
+
<StatRow label="Usage" value={stats.usagePercent !== null ? `${stats.usagePercent.toFixed(1)}%` : 'N/A'} highlight="amber" />
|
|
122
|
+
<StatRow label="Input Tokens" value={formatNumber(stats.inputTokens)} />
|
|
123
|
+
|
|
124
|
+
<StatRow label="Output Tokens" value={formatNumber(stats.outputTokens)} />
|
|
125
|
+
<StatRow label="Reasoning Tokens" value={formatNumber(stats.reasoningTokens)} />
|
|
126
|
+
|
|
127
|
+
<StatRow label="Cache Tokens (r/w)" value={`${stats.cacheTokensRead} / ${stats.cacheTokensWrite}`} />
|
|
128
|
+
<StatRow label="User Messages" value={stats.userMessages} />
|
|
129
|
+
|
|
130
|
+
<StatRow label="Assistant Messages" value={stats.assistantMessages} />
|
|
131
|
+
<StatRow label="Total Cost" value={`$${stats.totalCost.toFixed(2)}`} highlight="emerald" />
|
|
132
|
+
|
|
133
|
+
<StatRow label="First Message" value={session.messages[0] ? formatDate(session.messages[0].timestamp) : 'N/A'} />
|
|
134
|
+
<StatRow label="Last Activity" value={formatDate(session.updatedAt)} />
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Context Breakdown Visualization */}
|
|
138
|
+
<div>
|
|
139
|
+
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-3">
|
|
140
|
+
Context Breakdown
|
|
141
|
+
</p>
|
|
142
|
+
<div className="space-y-2">
|
|
143
|
+
<div className="h-1.5 bg-zinc-900 rounded-full overflow-hidden flex">
|
|
144
|
+
<div
|
|
145
|
+
className="bg-emerald-500"
|
|
146
|
+
style={{ width: `${stats.contextBreakdown.user}%` }}
|
|
147
|
+
/>
|
|
148
|
+
<div
|
|
149
|
+
className="bg-purple-500"
|
|
150
|
+
style={{ width: `${stats.contextBreakdown.assistant}%` }}
|
|
151
|
+
/>
|
|
152
|
+
<div
|
|
153
|
+
className="bg-amber-500"
|
|
154
|
+
style={{ width: `${stats.contextBreakdown.toolCalls}%` }}
|
|
155
|
+
/>
|
|
156
|
+
<div
|
|
157
|
+
className="bg-zinc-700"
|
|
158
|
+
style={{ width: `${stats.contextBreakdown.other}%` }}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="flex items-center gap-3 text-[9px] text-zinc-400 flex-wrap">
|
|
162
|
+
<span className="flex items-center gap-1">
|
|
163
|
+
<div className="w-2 h-2 rounded-full bg-emerald-500" />
|
|
164
|
+
User {stats.contextBreakdown.user.toFixed(1)}%
|
|
165
|
+
</span>
|
|
166
|
+
<span className="flex items-center gap-1">
|
|
167
|
+
<div className="w-2 h-2 rounded-full bg-purple-500" />
|
|
168
|
+
Assistant {stats.contextBreakdown.assistant.toFixed(1)}%
|
|
169
|
+
</span>
|
|
170
|
+
<span className="flex items-center gap-1">
|
|
171
|
+
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
|
172
|
+
Tool Calls {stats.contextBreakdown.toolCalls.toFixed(1)}%
|
|
173
|
+
</span>
|
|
174
|
+
<span className="flex items-center gap-1">
|
|
175
|
+
<div className="w-2 h-2 rounded-full bg-zinc-700" />
|
|
176
|
+
Other {stats.contextBreakdown.other.toFixed(1)}%
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{/* Raw Messages List */}
|
|
183
|
+
<div>
|
|
184
|
+
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-3">
|
|
185
|
+
Raw messages
|
|
186
|
+
</p>
|
|
187
|
+
<div className="max-h-64 overflow-y-auto custom-scrollbar space-y-1">
|
|
188
|
+
{session.messages.length === 0 ? (
|
|
189
|
+
<div className="p-4 rounded-xl border border-dashed border-zinc-800 text-center">
|
|
190
|
+
<p className="text-[10px] text-zinc-500 italic">No messages yet</p>
|
|
191
|
+
</div>
|
|
192
|
+
) : (
|
|
193
|
+
session.messages.map(msg => (
|
|
194
|
+
<button
|
|
195
|
+
key={msg.id}
|
|
196
|
+
onClick={() => copyMessageId(msg.id)}
|
|
197
|
+
className="w-full flex items-center gap-2 text-[9px] font-mono p-2 rounded bg-zinc-900/40 border border-zinc-800 hover:bg-zinc-800/60 hover:border-cyan-500/30 transition-all cursor-pointer group"
|
|
198
|
+
>
|
|
199
|
+
<span className={`font-bold ${
|
|
200
|
+
msg.role === 'user' ? 'text-emerald-500' :
|
|
201
|
+
msg.role === 'assistant' ? 'text-purple-500' :
|
|
202
|
+
'text-zinc-500'
|
|
203
|
+
}`}>
|
|
204
|
+
{msg.role}
|
|
205
|
+
</span>
|
|
206
|
+
<span className="text-zinc-600">•</span>
|
|
207
|
+
<span className="text-zinc-400 truncate flex-1 text-left">
|
|
208
|
+
{msg.id}
|
|
209
|
+
</span>
|
|
210
|
+
<span className="text-zinc-600 text-[8px] shrink-0">
|
|
211
|
+
{formatTime(msg.timestamp)}
|
|
212
|
+
</span>
|
|
213
|
+
{copiedId === msg.id ? (
|
|
214
|
+
<CheckCircle2 size={12} className="text-emerald-500 shrink-0" />
|
|
215
|
+
) : (
|
|
216
|
+
<Copy size={12} className="text-zinc-600 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
|
217
|
+
)}
|
|
218
|
+
</button>
|
|
219
|
+
))
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Helper Components
|
|
228
|
+
function StatRow({
|
|
229
|
+
label,
|
|
230
|
+
value,
|
|
231
|
+
mono = false,
|
|
232
|
+
highlight
|
|
233
|
+
}: {
|
|
234
|
+
label: string;
|
|
235
|
+
value: string | number;
|
|
236
|
+
mono?: boolean;
|
|
237
|
+
highlight?: 'cyan' | 'amber' | 'emerald';
|
|
238
|
+
}) {
|
|
239
|
+
const highlightColors = {
|
|
240
|
+
cyan: 'text-cyan-500',
|
|
241
|
+
amber: 'text-amber-500',
|
|
242
|
+
emerald: 'text-emerald-500',
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div className="p-2 rounded-lg bg-zinc-900/40 border border-zinc-800/60">
|
|
247
|
+
<p className="text-[9px] text-zinc-500 font-black uppercase tracking-widest mb-1">
|
|
248
|
+
{label}
|
|
249
|
+
</p>
|
|
250
|
+
<p className={`text-sm font-bold ${mono ? 'font-mono' : ''} ${
|
|
251
|
+
highlight ? highlightColors[highlight] : 'text-zinc-200'
|
|
252
|
+
}`}>
|
|
253
|
+
{value}
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Calculation Logic
|
|
260
|
+
function calculateStats(session: Session): SessionStatsData {
|
|
261
|
+
let totalTokens = 0;
|
|
262
|
+
let inputTokens = 0;
|
|
263
|
+
let outputTokens = 0;
|
|
264
|
+
let reasoningTokens = 0;
|
|
265
|
+
let cacheTokensRead = 0;
|
|
266
|
+
let cacheTokensWrite = 0;
|
|
267
|
+
let userMessages = 0;
|
|
268
|
+
let assistantMessages = 0;
|
|
269
|
+
|
|
270
|
+
// Aggregate from all messages
|
|
271
|
+
session.messages.forEach(msg => {
|
|
272
|
+
const usage = (msg as any).metadata?.usage;
|
|
273
|
+
if (usage) {
|
|
274
|
+
totalTokens += usage.totalTokens || 0;
|
|
275
|
+
inputTokens += usage.promptTokens || 0;
|
|
276
|
+
outputTokens += usage.completionTokens || 0;
|
|
277
|
+
reasoningTokens += usage.reasoningTokens || 0;
|
|
278
|
+
cacheTokensRead += usage.cacheReadTokens || usage.cachedTokens || 0;
|
|
279
|
+
cacheTokensWrite += usage.cacheWriteTokens || usage.cacheCreationTokens || 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (msg.role === 'user') userMessages++;
|
|
283
|
+
if (msg.role === 'assistant') assistantMessages++;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Get model info from session metadata
|
|
287
|
+
const metadata = (session as any).metadata || {};
|
|
288
|
+
const modelId = metadata.modelId || 'unknown';
|
|
289
|
+
const providerId = metadata.providerId || getProviderFromModelId(modelId);
|
|
290
|
+
const contextLimit = MODEL_CONTEXT_LIMITS[modelId] || null;
|
|
291
|
+
|
|
292
|
+
// Calculate cost
|
|
293
|
+
const pricing = MODEL_PRICING[modelId];
|
|
294
|
+
const totalCost = pricing
|
|
295
|
+
? (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output
|
|
296
|
+
: 0;
|
|
297
|
+
|
|
298
|
+
// Calculate usage percentage
|
|
299
|
+
const usagePercent = contextLimit ? (totalTokens / contextLimit) * 100 : null;
|
|
300
|
+
|
|
301
|
+
// Context breakdown (simplified estimation based on message counts)
|
|
302
|
+
// In reality, this would require counting tokens per message role
|
|
303
|
+
const estimatedUserTokens = userMessages > 0 ? Math.floor(totalTokens * 0.1) : 0;
|
|
304
|
+
const estimatedToolTokens = totalTokens > 0 ? Math.floor(totalTokens * 0.7) : 0;
|
|
305
|
+
const estimatedAssistantTokens = totalTokens - estimatedUserTokens - estimatedToolTokens;
|
|
306
|
+
const estimatedOtherTokens = 0;
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
totalTokens,
|
|
310
|
+
inputTokens,
|
|
311
|
+
outputTokens,
|
|
312
|
+
reasoningTokens,
|
|
313
|
+
cacheTokensRead,
|
|
314
|
+
cacheTokensWrite,
|
|
315
|
+
userMessages,
|
|
316
|
+
assistantMessages,
|
|
317
|
+
modelId,
|
|
318
|
+
modelName: MODEL_DISPLAY_NAMES[modelId] || modelId,
|
|
319
|
+
providerId: PROVIDER_NAMES[providerId] || providerId,
|
|
320
|
+
contextLimit,
|
|
321
|
+
totalCost,
|
|
322
|
+
usagePercent,
|
|
323
|
+
contextBreakdown: {
|
|
324
|
+
user: totalTokens > 0 ? (estimatedUserTokens / totalTokens) * 100 : 0,
|
|
325
|
+
assistant: totalTokens > 0 ? (estimatedAssistantTokens / totalTokens) * 100 : 0,
|
|
326
|
+
toolCalls: totalTokens > 0 ? (estimatedToolTokens / totalTokens) * 100 : 0,
|
|
327
|
+
other: totalTokens > 0 ? (estimatedOtherTokens / totalTokens) * 100 : 0,
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Utility Functions
|
|
333
|
+
function truncateId(id: string): string {
|
|
334
|
+
return id.length > 24 ? `${id.slice(0, 24)}...` : id;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function formatNumber(num: number | null): string {
|
|
338
|
+
if (num === null || num === undefined) return 'N/A';
|
|
339
|
+
return num.toLocaleString();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function formatDate(date: string | Date | undefined): string {
|
|
343
|
+
if (!date) return 'N/A';
|
|
344
|
+
const d = new Date(date);
|
|
345
|
+
return d.toLocaleString('en-US', {
|
|
346
|
+
month: 'short',
|
|
347
|
+
day: 'numeric',
|
|
348
|
+
year: 'numeric',
|
|
349
|
+
hour: 'numeric',
|
|
350
|
+
minute: '2-digit',
|
|
351
|
+
hour12: true
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function formatTime(timestamp: string | Date): string {
|
|
356
|
+
const d = new Date(timestamp);
|
|
357
|
+
return d.toLocaleTimeString('en-US', {
|
|
358
|
+
hour: 'numeric',
|
|
359
|
+
minute: '2-digit',
|
|
360
|
+
hour12: true
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function getProviderFromModelId(modelId: string): string {
|
|
365
|
+
if (modelId.startsWith('gemini')) return 'google';
|
|
366
|
+
if (modelId.startsWith('claude')) return 'anthropic';
|
|
367
|
+
if (modelId.startsWith('gpt') || modelId.startsWith('o1') || modelId.startsWith('o3')) return 'openai';
|
|
368
|
+
return 'unknown';
|
|
369
|
+
}
|