Skip to content

Commit c9edfb3

Browse files
committed
add gemini stats window
1 parent 03c025a commit c9edfb3

File tree

6 files changed

+666
-5
lines changed

6 files changed

+666
-5
lines changed

runtime/backend/gemini-agent.js

Lines changed: 225 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,218 @@ const GEMINI_BUNDLE = join(REPO_ROOT, 'bundle', 'gemini.js');
2222
const LOG_DIR = join(REPO_ROOT, 'logs', 'agent');
2323
const DEBUG_AGENT = process.env.DEBUG_AGENT === 'true';
2424

25+
let latestUsageRecord = null;
26+
let aggregateUsage = null;
27+
28+
const emptyDecisions = () => ({
29+
accept: 0,
30+
reject: 0,
31+
modify: 0,
32+
auto_accept: 0,
33+
});
34+
35+
const deepClone = (value) => {
36+
if (typeof globalThis.structuredClone === 'function') {
37+
return globalThis.structuredClone(value);
38+
}
39+
return JSON.parse(JSON.stringify(value));
40+
};
41+
42+
const normalizeModelStats = (stats) => ({
43+
api: {
44+
totalRequests: stats?.api?.totalRequests ?? 0,
45+
totalErrors: stats?.api?.totalErrors ?? 0,
46+
totalLatencyMs: stats?.api?.totalLatencyMs ?? 0,
47+
},
48+
tokens: {
49+
prompt: stats?.tokens?.prompt ?? 0,
50+
candidates: stats?.tokens?.candidates ?? 0,
51+
total: stats?.tokens?.total ?? 0,
52+
cached: stats?.tokens?.cached ?? 0,
53+
thoughts: stats?.tokens?.thoughts ?? 0,
54+
tool: stats?.tokens?.tool ?? 0,
55+
},
56+
});
57+
58+
const normalizeToolStats = (stats) => ({
59+
count: stats?.count ?? 0,
60+
success: stats?.success ?? 0,
61+
fail: stats?.fail ?? 0,
62+
durationMs: stats?.durationMs ?? 0,
63+
decisions: {
64+
accept: stats?.decisions?.accept ?? 0,
65+
reject: stats?.decisions?.reject ?? 0,
66+
modify: stats?.decisions?.modify ?? 0,
67+
auto_accept: stats?.decisions?.auto_accept ?? 0,
68+
},
69+
});
70+
71+
const mergeModelStats = (target, source) => {
72+
for (const [modelName, modelStats] of Object.entries(source?.models ?? {})) {
73+
const existing = target.models[modelName]
74+
? normalizeModelStats(target.models[modelName])
75+
: normalizeModelStats();
76+
const incoming = normalizeModelStats(modelStats);
77+
existing.api.totalRequests += incoming.api.totalRequests;
78+
existing.api.totalErrors += incoming.api.totalErrors;
79+
existing.api.totalLatencyMs += incoming.api.totalLatencyMs;
80+
existing.tokens.prompt += incoming.tokens.prompt;
81+
existing.tokens.candidates += incoming.tokens.candidates;
82+
existing.tokens.total += incoming.tokens.total;
83+
existing.tokens.cached += incoming.tokens.cached;
84+
existing.tokens.thoughts += incoming.tokens.thoughts;
85+
existing.tokens.tool += incoming.tokens.tool;
86+
target.models[modelName] = existing;
87+
}
88+
};
89+
90+
const mergeToolStats = (target, source) => {
91+
const incomingTools = source?.tools;
92+
if (!incomingTools) return;
93+
94+
target.tools.totalCalls += incomingTools.totalCalls ?? 0;
95+
target.tools.totalSuccess += incomingTools.totalSuccess ?? 0;
96+
target.tools.totalFail += incomingTools.totalFail ?? 0;
97+
target.tools.totalDurationMs += incomingTools.totalDurationMs ?? 0;
98+
99+
const incomingDecisions = incomingTools.totalDecisions ?? emptyDecisions();
100+
target.tools.totalDecisions.accept += incomingDecisions.accept ?? 0;
101+
target.tools.totalDecisions.reject += incomingDecisions.reject ?? 0;
102+
target.tools.totalDecisions.modify += incomingDecisions.modify ?? 0;
103+
target.tools.totalDecisions.auto_accept += incomingDecisions.auto_accept ?? 0;
104+
105+
for (const [toolName, stats] of Object.entries(incomingTools.byName ?? {})) {
106+
const existing = target.tools.byName[toolName]
107+
? normalizeToolStats(target.tools.byName[toolName])
108+
: normalizeToolStats();
109+
const incoming = normalizeToolStats(stats);
110+
existing.count += incoming.count;
111+
existing.success += incoming.success;
112+
existing.fail += incoming.fail;
113+
existing.durationMs += incoming.durationMs;
114+
existing.decisions.accept += incoming.decisions.accept;
115+
existing.decisions.reject += incoming.decisions.reject;
116+
existing.decisions.modify += incoming.decisions.modify;
117+
existing.decisions.auto_accept += incoming.decisions.auto_accept;
118+
target.tools.byName[toolName] = existing;
119+
}
120+
};
121+
122+
const mergeFileStats = (target, source) => {
123+
target.files.totalLinesAdded += source?.files?.totalLinesAdded ?? 0;
124+
target.files.totalLinesRemoved += source?.files?.totalLinesRemoved ?? 0;
125+
};
126+
127+
const mergeStats = (target, source) => {
128+
mergeModelStats(target, source);
129+
mergeToolStats(target, source);
130+
mergeFileStats(target, source);
131+
};
132+
133+
const computeSummary = (stats) => {
134+
const summary = {
135+
totalRequests: 0,
136+
totalPromptTokens: 0,
137+
totalOutputTokens: 0,
138+
totalTokens: 0,
139+
totalCachedTokens: 0,
140+
models: [],
141+
};
142+
143+
for (const [name, model] of Object.entries(stats?.models ?? {})) {
144+
const requests = model.api?.totalRequests ?? 0;
145+
const promptTokens = model.tokens?.prompt ?? 0;
146+
const outputTokens = model.tokens?.candidates ?? 0;
147+
const totalTokens = model.tokens?.total ?? promptTokens + outputTokens;
148+
const cachedTokens = model.tokens?.cached ?? 0;
149+
150+
summary.totalRequests += requests;
151+
summary.totalPromptTokens += promptTokens;
152+
summary.totalOutputTokens += outputTokens;
153+
summary.totalTokens += totalTokens;
154+
summary.totalCachedTokens += cachedTokens;
155+
156+
summary.models.push({
157+
name,
158+
requests,
159+
promptTokens,
160+
outputTokens,
161+
totalTokens,
162+
cachedTokens,
163+
});
164+
}
165+
166+
return summary;
167+
};
168+
169+
const recordUsageStats = (stats) => {
170+
if (!stats) return;
171+
172+
const timestamp = new Date().toISOString();
173+
const clonedStats = deepClone(stats);
174+
const summary = computeSummary(clonedStats);
175+
176+
latestUsageRecord = {
177+
stats: clonedStats,
178+
summary,
179+
recordedAt: timestamp,
180+
};
181+
182+
if (!aggregateUsage) {
183+
aggregateUsage = {
184+
stats: deepClone(clonedStats),
185+
summary: computeSummary(clonedStats),
186+
sessions: 1,
187+
since: timestamp,
188+
updatedAt: timestamp,
189+
};
190+
return;
191+
}
192+
193+
mergeStats(aggregateUsage.stats, stats);
194+
aggregateUsage.summary = computeSummary(aggregateUsage.stats);
195+
aggregateUsage.sessions += 1;
196+
aggregateUsage.updatedAt = timestamp;
197+
};
198+
199+
const parseGeminiJsonOutput = (rawOutput) => {
200+
if (!rawOutput) return null;
201+
const trimmed = rawOutput.trim();
202+
if (!trimmed) return null;
203+
204+
const firstBraceIndex = trimmed.indexOf('{');
205+
if (firstBraceIndex === -1) return null;
206+
207+
const jsonCandidate = trimmed.slice(firstBraceIndex);
208+
209+
try {
210+
return JSON.parse(jsonCandidate);
211+
} catch (error) {
212+
console.warn('⚠️ Failed to parse Gemini JSON output:', error);
213+
return null;
214+
}
215+
};
216+
217+
export const getUsageSummary = () => {
218+
const latest = latestUsageRecord
219+
? {
220+
...latestUsageRecord,
221+
stats: deepClone(latestUsageRecord.stats),
222+
summary: { ...latestUsageRecord.summary },
223+
}
224+
: null;
225+
226+
const aggregate = aggregateUsage
227+
? {
228+
...aggregateUsage,
229+
stats: deepClone(aggregateUsage.stats),
230+
summary: { ...aggregateUsage.summary },
231+
}
232+
: null;
233+
234+
return { latest, aggregate };
235+
};
236+
25237
function ensureNodeVersion() {
26238
const [major] = process.versions.node.split('.');
27239
const majorNumber = Number(major);
@@ -112,7 +324,7 @@ Please apply the requested update and include DONE at the very end.`;
112324
'--prompt',
113325
promptWithContext,
114326
'--output-format',
115-
'text',
327+
'json',
116328
];
117329

118330
return new Promise((resolve, reject) => {
@@ -151,6 +363,13 @@ Please apply the requested update and include DONE at the very end.`;
151363

152364
if (code === 0) {
153365
const trimmedOutput = output.trim();
366+
const parsed = parseGeminiJsonOutput(trimmedOutput);
367+
const responseText = parsed?.response ?? trimmedOutput;
368+
const stats = parsed?.stats;
369+
370+
if (stats) {
371+
recordUsageStats(stats);
372+
}
154373

155374
if (/Error when talking to Gemini API/i.test(errorOutput)) {
156375
const logPath = await writeDebugLog(
@@ -165,7 +384,7 @@ Please apply the requested update and include DONE at the very end.`;
165384
return;
166385
}
167386

168-
if (!trimmedOutput) {
387+
if (!responseText.trim()) {
169388
const logPath = await writeDebugLog(
170389
`${new Date().toISOString().replace(/[:.]/g, '-')}-empty-output.log`,
171390
`User command: ${userCommand}\nArgs: ${JSON.stringify(args)}\nStdout empty.\nStderr:\n${errorOutput}\n`,
@@ -176,7 +395,7 @@ Please apply the requested update and include DONE at the very end.`;
176395
return;
177396
}
178397

179-
if (!/DONE\b/.test(trimmedOutput)) {
398+
if (!/DONE\b/.test(responseText)) {
180399
console.warn('⚠️ Gemini agent finished without DONE confirmation.');
181400
}
182401

@@ -188,7 +407,9 @@ Please apply the requested update and include DONE at the very end.`;
188407
console.log('✅ Gemini agent completed successfully');
189408
resolve({
190409
success: true,
191-
output,
410+
output: responseText,
411+
rawOutput: output,
412+
stats: stats ? deepClone(stats) : null,
192413
modifiedFile: GENERATED_CONTENT_PATH,
193414
debugLogPath: logPath,
194415
});

runtime/backend/server.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import express from 'express';
1010
import cors from 'cors';
1111
import { fileURLToPath } from 'node:url';
1212
import { dirname, join } from 'node:path';
13-
import { invokeGeminiAgent, getGeneratedContentPath } from './gemini-agent.js';
13+
import {
14+
invokeGeminiAgent,
15+
getGeneratedContentPath,
16+
getUsageSummary,
17+
} from './gemini-agent.js';
1418
import { generateSmartExperience } from './smart-simulator.js';
1519
import { checkGeminiAuth } from './check-auth.js';
1620

@@ -121,6 +125,23 @@ app.get('/api/status', (req, res) => {
121125
});
122126
});
123127

128+
app.get('/api/gemini-stats', (req, res) => {
129+
const summary = getUsageSummary();
130+
131+
if (!summary.latest && !summary.aggregate) {
132+
res.json({
133+
success: false,
134+
message: 'No Gemini API usage has been recorded in this session.',
135+
});
136+
return;
137+
}
138+
139+
res.json({
140+
success: true,
141+
summary,
142+
});
143+
});
144+
124145
app.listen(PORT, async () => {
125146
console.log(
126147
`🚀 Generative Computer Backend running on http://localhost:${PORT}`,

runtime/frontend/src/App.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Desktop from './components/Desktop';
99
import type { WindowData } from './components/Desktop';
1010
import CommandInput from './components/CommandInput';
1111
import GeneratedContent from './components/GeneratedContent';
12+
import GeminiStatsWindow from './components/GeminiStatsWindow';
1213
import DrawingPadApp from './components/DrawingPadApp';
1314
import './App.css';
1415

@@ -195,6 +196,15 @@ function App() {
195196
});
196197
}, [openStaticWindow]);
197198

199+
const handleOpenUsageStats = useCallback(() => {
200+
openStaticWindow({
201+
id: 'gemini-usage',
202+
title: 'Gemini Usage',
203+
content: <GeminiStatsWindow />,
204+
position: { x: 180, y: 120 },
205+
});
206+
}, [openStaticWindow]);
207+
198208
const handleCommand = useCallback(
199209
async (command: string) => {
200210
console.log('Command received:', command);
@@ -340,6 +350,7 @@ function App() {
340350
onOpenRecycleBin={handleOpenRecycleBin}
341351
onOpenDemoVideo={handleOpenDemoVideo}
342352
onOpenDrawingPad={handleOpenDrawingPad}
353+
onOpenUsageStats={handleOpenUsageStats}
343354
/>
344355

345356
<CommandInput

runtime/frontend/src/components/Desktop.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface DesktopProps {
2222
onOpenRecycleBin: () => void;
2323
onOpenDemoVideo: () => void;
2424
onOpenDrawingPad: () => void;
25+
onOpenUsageStats: () => void;
2526
}
2627

2728
export default function Desktop({
@@ -31,6 +32,7 @@ export default function Desktop({
3132
onOpenRecycleBin,
3233
onOpenDemoVideo,
3334
onOpenDrawingPad,
35+
onOpenUsageStats,
3436
}: DesktopProps) {
3537
return (
3638
<div className="desktop">
@@ -59,6 +61,14 @@ export default function Desktop({
5961
<div className="icon-image">🎨</div>
6062
<div className="icon-label">Neon Sketch</div>
6163
</button>
64+
<button
65+
type="button"
66+
className="desktop-icon"
67+
onClick={onOpenUsageStats}
68+
>
69+
<div className="icon-image">📊</div>
70+
<div className="icon-label">Gemini Usage</div>
71+
</button>
6272
<button
6373
type="button"
6474
className="desktop-icon"

0 commit comments

Comments
 (0)