Skip to content

Commit 7a1ad0c

Browse files
milispclaude
andcommitted
refactor: optimize imports and improve usage analysis accuracy
- Move Tauri API imports to top level in ClipboardImagePaste and MediaSelector - Optimize media utils by removing dynamic imports - Enhance usage analysis with proper token calculation and project detection - Add support for more model types in cost calculation - Improve session parsing with better token data extraction 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 935a729 commit 7a1ad0c

File tree

4 files changed

+97
-48
lines changed

4 files changed

+97
-48
lines changed

src/components/chat/ClipboardImagePaste.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
22
import { useChatInputStore } from '@/stores/chatInputStore';
33
import { createMediaAttachment } from '@/utils/mediaUtils';
44
import { writeFile, BaseDirectory } from '@tauri-apps/plugin-fs';
5+
import { appCacheDir } from '@tauri-apps/api/path';
56

67
interface ClipboardImagePasteProps {
78
children: React.ReactNode;
@@ -40,7 +41,6 @@ export const ClipboardImagePaste: React.FC<ClipboardImagePasteProps> = ({ childr
4041
await writeFile(tempPath, uint8Array, { baseDir: BaseDirectory.AppCache });
4142

4243
// Get the full path for the media attachment
43-
const { appCacheDir } = await import('@tauri-apps/api/path');
4444
const fullPath = `${await appCacheDir()}/temp/${fileName}`;
4545

4646
// Create media attachment

src/components/chat/MediaSelector.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useChatInputStore } from '@/stores/chatInputStore';
1111
import { isMediaFile, createMediaAttachment } from '@/utils/mediaUtils';
1212
import { open } from '@tauri-apps/plugin-dialog';
1313
import { writeFile, BaseDirectory } from '@tauri-apps/plugin-fs';
14+
import { appCacheDir } from '@tauri-apps/api/path';
1415

1516
export const MediaSelector: React.FC = () => {
1617
const { addMediaAttachment } = useChatInputStore();
@@ -39,7 +40,6 @@ export const MediaSelector: React.FC = () => {
3940
await writeFile(tempPath, uint8Array, { baseDir: BaseDirectory.AppCache });
4041

4142
// Get the full path for the media attachment
42-
const { appCacheDir } = await import('@tauri-apps/api/path');
4343
const fullPath = `${await appCacheDir()}/temp/${fileName}`;
4444

4545
// Create media attachment

src/utils/mediaUtils.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { MediaAttachment } from '@/types/chat';
2+
import { readFile } from '@tauri-apps/plugin-fs';
23

34
// Supported image formats
45
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
@@ -94,14 +95,12 @@ export const createMediaAttachment = async (filePath: string): Promise<MediaAtta
9495
*/
9596
export const readFileAsBase64 = async (filePath: string): Promise<string> => {
9697
try {
97-
// Dynamic import of Tauri API - use plugin-fs for Tauri v2
98-
const fs = await import('@tauri-apps/plugin-fs');
99-
const bytes = await fs.readFile(filePath);
98+
const bytes = await readFile(filePath);
10099
const base64 = btoa(String.fromCharCode(...new Uint8Array(bytes)));
101100
const mimeType = getMimeType(filePath);
102101
return `data:${mimeType};base64,${base64}`;
103102
} catch (error) {
104103
console.error('Failed to read file as base64:', error);
105104
throw error;
106105
}
107-
};
106+
};

src/utils/usageAnalysis.ts

Lines changed: 92 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ export interface TokenUsage {
88
total_tokens: number;
99
}
1010

11+
// Helper function to calculate blended total like the CLI does
12+
function getBlendedTotal(usage: TokenUsage): number {
13+
const nonCachedInput = usage.input_tokens - (usage.cached_input_tokens || 0);
14+
return nonCachedInput + usage.output_tokens;
15+
}
16+
1117
export interface SessionData {
1218
id: string;
1319
timestamp: string;
@@ -47,21 +53,27 @@ export interface UsageSummary {
4753

4854
// Cost per million tokens (estimated based on common model pricing)
4955
const MODEL_COSTS: Record<string, { input: number; output: number; cache_write: number; cache_read: number }> = {
50-
'gpt-5-high': { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
51-
'gpt-5': { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
52-
'gpt-5-mini': { input: 0.25, output: 1.25, cache_write: 0.3, cache_read: 0.03 },
56+
'claude-3-5-sonnet-20241022': { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
57+
'claude-3-5-haiku-20241022': { input: 0.8, output: 4, cache_write: 1, cache_read: 0.08 },
58+
'claude-3-opus-20240229': { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
59+
'gemini-2.5-flash-lite': { input: 0.075, output: 0.3, cache_write: 0.01875, cache_read: 0.00188 },
60+
'gemini-2.5-flash': { input: 0.075, output: 0.3, cache_write: 0.01875, cache_read: 0.00188 },
61+
'gpt-5': { input: 2.5, output: 10, cache_write: 1.25, cache_read: 0.125 },
62+
'gpt-4o': { input: 2.5, output: 10, cache_write: 1.25, cache_read: 0.125 },
5363
'gpt-4': { input: 30, output: 60, cache_write: 30, cache_read: 3 },
5464
'gpt-3.5-turbo': { input: 0.5, output: 1.5, cache_write: 0.5, cache_read: 0.05 },
5565
'llama3.2': { input: 0, output: 0, cache_write: 0, cache_read: 0 }, // OSS model
66+
'mistral': { input: 0, output: 0, cache_write: 0, cache_read: 0 }, // OSS model
5667
};
5768

5869
function calculateTokenCost(usage: TokenUsage, model: string): number {
5970
const costs = MODEL_COSTS[model] || MODEL_COSTS['claude-3-5-sonnet-20241022']; // Default to Sonnet
6071

61-
const inputCost = (usage.input_tokens / 1_000_000) * costs.input;
72+
const nonCachedInput = usage.input_tokens - (usage.cached_input_tokens || 0);
73+
const inputCost = (nonCachedInput / 1_000_000) * costs.input;
6274
const outputCost = (usage.output_tokens / 1_000_000) * costs.output;
6375
const cacheWriteCost = ((usage.cached_input_tokens || 0) / 1_000_000) * costs.cache_write;
64-
const cacheReadCost = 0; // Assuming cache read is included in input tokens
76+
const cacheReadCost = 0; // Cache reads are typically much cheaper
6577

6678
return inputCost + outputCost + cacheWriteCost + cacheReadCost;
6779
}
@@ -100,23 +112,49 @@ export async function parseSessionFile(filePath: string): Promise<SessionMetrics
100112
let totalInputTokens = 0;
101113
let totalOutputTokens = 0;
102114
let totalCacheTokens = 0;
103-
let model = 'claude-3-5-sonnet-20241022'; // Default
115+
let model = 'gpt-5'; // Default based on config
104116

105117
// Count messages as a proxy for usage when actual token data is not available
106118
let messageCount = 0;
107119
let hasUserMessages = false;
108120
let hasAssistantMessages = false;
121+
let projectFromCwd = '';
109122

110123
for (const line of lines) {
111124
try {
112125
const event = JSON.parse(line);
113126

114-
// Look for token count events (actual usage data)
115-
if (event.msg?.type === 'token_count' && event.msg.usage) {
116-
const usage: TokenUsage = event.msg.usage;
117-
totalInputTokens += usage.input_tokens;
118-
totalOutputTokens += usage.output_tokens;
119-
totalCacheTokens += usage.cached_input_tokens || 0;
127+
// Look for token count events - handle various event structures from codex CLI
128+
// Based on the CLI source, TokenCount events come as EventMsg::TokenCount(TokenUsage)
129+
if (event.msg && typeof event.msg === 'object') {
130+
// Direct TokenUsage structure (EventMsg::TokenCount)
131+
if ('input_tokens' in event.msg || 'output_tokens' in event.msg || 'total_tokens' in event.msg) {
132+
const usage = event.msg as TokenUsage;
133+
// Use cumulative values as they represent session totals
134+
if (usage.input_tokens !== undefined) {
135+
totalInputTokens = Math.max(totalInputTokens, usage.input_tokens);
136+
}
137+
if (usage.output_tokens !== undefined) {
138+
totalOutputTokens = Math.max(totalOutputTokens, usage.output_tokens);
139+
}
140+
if (usage.cached_input_tokens !== undefined) {
141+
totalCacheTokens = Math.max(totalCacheTokens, usage.cached_input_tokens);
142+
}
143+
}
144+
145+
// Nested structure with type field
146+
if (event.msg.type === 'token_count' && event.msg.usage) {
147+
const usage: TokenUsage = event.msg.usage;
148+
if (usage.input_tokens !== undefined) {
149+
totalInputTokens = Math.max(totalInputTokens, usage.input_tokens);
150+
}
151+
if (usage.output_tokens !== undefined) {
152+
totalOutputTokens = Math.max(totalOutputTokens, usage.output_tokens);
153+
}
154+
if (usage.cached_input_tokens !== undefined) {
155+
totalCacheTokens = Math.max(totalCacheTokens, usage.cached_input_tokens);
156+
}
157+
}
120158
}
121159

122160
// Look for messages to estimate usage when no token data available
@@ -129,57 +167,69 @@ export async function parseSessionFile(filePath: string): Promise<SessionMetrics
129167
}
130168
}
131169

132-
// Extract model info from various sources
170+
// Extract project info from environment context (cwd)
171+
if (event.content && Array.isArray(event.content)) {
172+
for (const content of event.content) {
173+
if (content.type === 'input_text' && content.text.includes('<cwd>')) {
174+
const cwdMatch = content.text.match(/<cwd>([^<]+)<\/cwd>/);
175+
if (cwdMatch && cwdMatch[1]) {
176+
projectFromCwd = cwdMatch[1].split('/').pop() || cwdMatch[1];
177+
}
178+
}
179+
}
180+
}
181+
182+
// Extract model info from various sources - default to what's actually configured
133183
if (event.msg?.model) {
134184
model = event.msg.model;
135185
}
136186
if (event.model) {
137187
model = event.model;
138188
}
139-
140-
// Check for git repo info to determine if it's Claude Code usage
141-
if (event.git?.repository_url && event.git.repository_url.includes('codexia')) {
142-
model = 'gpt-5-high'; // Likely using Claude 4 for development
143-
}
144189
} catch {
145190
// Skip invalid JSON lines
146191
}
147192
}
148193

149-
// If no actual token data, estimate based on messages
150-
if (totalInputTokens === 0 && totalOutputTokens === 0 && hasUserMessages && hasAssistantMessages) {
151-
// Rough estimation: user input ~100 tokens, assistant response ~300 tokens per exchange
194+
// If no actual token data, estimate based on messages for meaningful sessions
195+
if (totalInputTokens === 0 && totalOutputTokens === 0 && hasUserMessages && hasAssistantMessages && messageCount >= 2) {
196+
// Conservative estimation based on typical coding conversations
152197
const estimatedExchanges = Math.ceil(messageCount / 2);
153-
totalInputTokens = estimatedExchanges * 150; // Average input
154-
totalOutputTokens = estimatedExchanges * 400; // Average output
155-
totalCacheTokens = estimatedExchanges * 50; // Some caching
198+
totalInputTokens = estimatedExchanges * 200; // Average user input with context
199+
totalOutputTokens = estimatedExchanges * 600; // Average assistant response with code
200+
totalCacheTokens = estimatedExchanges * 100; // Context caching
156201
}
157202

158-
const totalTokens = totalInputTokens + totalOutputTokens;
203+
// Calculate total tokens using the same logic as CLI (blended total)
204+
const mockUsage: TokenUsage = {
205+
input_tokens: totalInputTokens,
206+
output_tokens: totalOutputTokens,
207+
cached_input_tokens: totalCacheTokens,
208+
total_tokens: totalInputTokens + totalOutputTokens,
209+
};
210+
const totalTokens = getBlendedTotal(mockUsage);
159211

160212
// Skip sessions with no meaningful activity
161213
if (totalTokens === 0 && messageCount < 2) {
162214
return null;
163215
}
164216

165-
// Extract project path from git repo or session path
166-
let projectPath = sessionData.git?.repository_url || filePath;
167-
if (projectPath.includes('/')) {
168-
projectPath = projectPath.split('/').pop() || projectPath;
169-
}
170-
if (projectPath.includes('.git')) {
171-
projectPath = projectPath.replace('.git', '');
172-
}
173-
if (projectPath.startsWith('git@github.com:')) {
174-
projectPath = projectPath.replace('git@github.com:', '').replace('.git', '');
217+
// Extract project path - prefer cwd from environment context, fallback to filename
218+
let projectPath = projectFromCwd;
219+
if (!projectPath) {
220+
// Fallback to extracting from file path
221+
projectPath = filePath.split('/').pop() || filePath;
222+
if (projectPath.includes('rollout-') && projectPath.endsWith('.jsonl')) {
223+
// For rollout files, use the directory name instead
224+
const pathParts = filePath.split('/');
225+
if (pathParts.length > 1) {
226+
// Try to get a meaningful project name from the path or use generic name
227+
projectPath = 'Unknown Session';
228+
}
229+
}
175230
}
176231

177-
const estimatedCost = calculateTokenCost({
178-
input_tokens: totalInputTokens,
179-
output_tokens: totalOutputTokens,
180-
cached_input_tokens: totalCacheTokens,
181-
total_tokens: totalTokens,
182-
}, model);
232+
const estimatedCost = calculateTokenCost(mockUsage, model);
183233

184234
return {
185235
sessionId: sessionData.id,
@@ -292,4 +342,4 @@ export async function calculateUsageSummary(): Promise<UsageSummary> {
292342
projectBreakdown,
293343
timelineData,
294344
};
295-
}
345+
}

0 commit comments

Comments
 (0)