mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-13 17:11:30 +00:00
Compare commits
2 Commits
v1.28.1
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce8ad0893 | ||
|
|
cc7f652044 |
@@ -585,7 +585,7 @@
|
|||||||
<p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p>
|
<p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p>
|
||||||
|
|
||||||
<h4>Response (Non-Streaming)</h4>
|
<h4>Response (Non-Streaming)</h4>
|
||||||
<p>JSON object containing session details, assistant messages only (filtered), and token usage summary. Content-Type: <code>application/json</code></p>
|
<p>JSON object containing session details and assistant messages only (filtered). Content-Type: <code>application/json</code></p>
|
||||||
|
|
||||||
<h4>Error Response</h4>
|
<h4>Error Response</h4>
|
||||||
<p>Returns error details with appropriate HTTP status code.</p>
|
<p>Returns error details with appropriate HTTP status code.</p>
|
||||||
@@ -674,21 +674,10 @@ data: {"type":"done"}</code></pre>
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "I've completed the task..."
|
"text": "I've completed the task..."
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"usage": {
|
|
||||||
"input_tokens": 150,
|
|
||||||
"output_tokens": 50
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tokens": {
|
|
||||||
"inputTokens": 150,
|
|
||||||
"outputTokens": 50,
|
|
||||||
"cacheReadTokens": 0,
|
|
||||||
"cacheCreationTokens": 0,
|
|
||||||
"totalTokens": 200
|
|
||||||
},
|
|
||||||
"projectPath": "/path/to/project",
|
"projectPath": "/path/to/project",
|
||||||
"branch": {
|
"branch": {
|
||||||
"name": "fix-authentication-bug-abc123",
|
"name": "fix-authentication-bug-abc123",
|
||||||
|
|||||||
@@ -274,46 +274,6 @@ function transformMessage(sdkMessage) {
|
|||||||
return sdkMessage;
|
return sdkMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts token usage from SDK result messages
|
|
||||||
* @param {Object} resultMessage - SDK result message
|
|
||||||
* @returns {Object|null} Token budget object or null
|
|
||||||
*/
|
|
||||||
function extractTokenBudget(resultMessage) {
|
|
||||||
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the first model's usage data
|
|
||||||
const modelKey = Object.keys(resultMessage.modelUsage)[0];
|
|
||||||
const modelData = resultMessage.modelUsage[modelKey];
|
|
||||||
|
|
||||||
if (!modelData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use cumulative tokens if available (tracks total for the session)
|
|
||||||
// Otherwise fall back to per-request tokens
|
|
||||||
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
|
|
||||||
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
|
|
||||||
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
|
|
||||||
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
|
|
||||||
|
|
||||||
// Total used = input + output + cache tokens
|
|
||||||
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
|
||||||
|
|
||||||
// Use configured context window budget from environment (default 160000)
|
|
||||||
// This is the user's budget limit, not the model's context window
|
|
||||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
|
||||||
|
|
||||||
// Token calc logged via token-budget WS event
|
|
||||||
|
|
||||||
return {
|
|
||||||
used: totalUsed,
|
|
||||||
total: contextWindow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles image processing for SDK queries
|
* Handles image processing for SDK queries
|
||||||
* Saves base64 images to temporary files and returns modified prompt with file paths
|
* Saves base64 images to temporary files and returns modified prompt with file paths
|
||||||
@@ -657,18 +617,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract and send token budget updates from result messages
|
|
||||||
if (message.type === 'result') {
|
|
||||||
const models = Object.keys(message.modelUsage || {});
|
|
||||||
if (models.length > 0) {
|
|
||||||
// Model info available in result message
|
|
||||||
}
|
|
||||||
const tokenBudgetData = extractTokenBudget(message);
|
|
||||||
if (tokenBudgetData) {
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up session on completion
|
// Clean up session on completion
|
||||||
|
|||||||
188
server/index.js
188
server/index.js
@@ -2218,194 +2218,6 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get token usage for a specific session
|
|
||||||
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { projectName, sessionId } = req.params;
|
|
||||||
const { provider = 'claude' } = req.query;
|
|
||||||
const homeDir = os.homedir();
|
|
||||||
|
|
||||||
// Allow only safe characters in sessionId
|
|
||||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
||||||
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
|
||||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
|
||||||
if (provider === 'cursor') {
|
|
||||||
return res.json({
|
|
||||||
used: 0,
|
|
||||||
total: 0,
|
|
||||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
||||||
unsupported: true,
|
|
||||||
message: 'Token usage tracking not available for Cursor sessions'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Gemini sessions - they are raw logs in our current setup
|
|
||||||
if (provider === 'gemini') {
|
|
||||||
return res.json({
|
|
||||||
used: 0,
|
|
||||||
total: 0,
|
|
||||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
||||||
unsupported: true,
|
|
||||||
message: 'Token usage tracking not available for Gemini sessions'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Codex sessions
|
|
||||||
if (provider === 'codex') {
|
|
||||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
||||||
|
|
||||||
// Find the session file by searching for the session ID
|
|
||||||
const findSessionFile = async (dir) => {
|
|
||||||
try {
|
|
||||||
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const found = await findSessionFile(fullPath);
|
|
||||||
if (found) return found;
|
|
||||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
|
||||||
return fullPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Skip directories we can't read
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
|
||||||
|
|
||||||
if (!sessionFilePath) {
|
|
||||||
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and parse the Codex JSONL file
|
|
||||||
let fileContent;
|
|
||||||
try {
|
|
||||||
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const lines = fileContent.trim().split('\n');
|
|
||||||
let totalTokens = 0;
|
|
||||||
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
||||||
|
|
||||||
// Find the latest token_count event with info (scan from end)
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
try {
|
|
||||||
const entry = JSON.parse(lines[i]);
|
|
||||||
|
|
||||||
// Codex stores token info in event_msg with type: "token_count"
|
|
||||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
|
||||||
const tokenInfo = entry.payload.info;
|
|
||||||
if (tokenInfo.total_token_usage) {
|
|
||||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
|
||||||
}
|
|
||||||
if (tokenInfo.model_context_window) {
|
|
||||||
contextWindow = tokenInfo.model_context_window;
|
|
||||||
}
|
|
||||||
break; // Stop after finding the latest token count
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// Skip lines that can't be parsed
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
used: totalTokens,
|
|
||||||
total: contextWindow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Claude sessions (default)
|
|
||||||
// Extract actual project path
|
|
||||||
let projectPath;
|
|
||||||
try {
|
|
||||||
projectPath = await extractProjectDirectory(projectName);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error extracting project directory:', error);
|
|
||||||
return res.status(500).json({ error: 'Failed to determine project path' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the JSONL file path
|
|
||||||
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
|
||||||
// The encoding replaces any non-alphanumeric character (except -) with -
|
|
||||||
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
||||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
||||||
|
|
||||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
|
||||||
|
|
||||||
// Constrain to projectDir
|
|
||||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
|
||||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
||||||
return res.status(400).json({ error: 'Invalid path' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and parse the JSONL file
|
|
||||||
let fileContent;
|
|
||||||
try {
|
|
||||||
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
||||||
}
|
|
||||||
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
||||||
}
|
|
||||||
const lines = fileContent.trim().split('\n');
|
|
||||||
|
|
||||||
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
||||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
|
||||||
let inputTokens = 0;
|
|
||||||
let cacheCreationTokens = 0;
|
|
||||||
let cacheReadTokens = 0;
|
|
||||||
|
|
||||||
// Find the latest assistant message with usage data (scan from end)
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
try {
|
|
||||||
const entry = JSON.parse(lines[i]);
|
|
||||||
|
|
||||||
// Only count assistant messages which have usage data
|
|
||||||
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
||||||
const usage = entry.message.usage;
|
|
||||||
|
|
||||||
// Use token counts from latest assistant message only
|
|
||||||
inputTokens = usage.input_tokens || 0;
|
|
||||||
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
||||||
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
||||||
|
|
||||||
break; // Stop after finding the latest assistant message
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// Skip lines that can't be parsed
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
|
||||||
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
used: totalUsed,
|
|
||||||
total: contextWindow,
|
|
||||||
breakdown: {
|
|
||||||
input: inputTokens,
|
|
||||||
cacheCreation: cacheCreationTokens,
|
|
||||||
cacheRead: cacheReadTokens
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading session token usage:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to read session token usage' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve React app for all other routes (excluding static files)
|
// Serve React app for all other routes (excluding static files)
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
// Skip requests for static assets (files with extensions)
|
// Skip requests for static assets (files with extensions)
|
||||||
|
|||||||
@@ -129,8 +129,7 @@ function transformCodexEvent(event) {
|
|||||||
|
|
||||||
case 'turn.completed':
|
case 'turn.completed':
|
||||||
return {
|
return {
|
||||||
type: 'turn_complete',
|
type: 'turn_complete'
|
||||||
usage: event.usage
|
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'turn.failed':
|
case 'turn.failed':
|
||||||
@@ -279,12 +278,6 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
error: terminalFailure
|
error: terminalFailure
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract and send token usage if available (normalized to match Claude format)
|
|
||||||
if (event.type === 'turn.completed' && event.usage) {
|
|
||||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
|
||||||
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send completion event
|
// Send completion event
|
||||||
|
|||||||
@@ -1618,7 +1618,6 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = [];
|
const messages = [];
|
||||||
let tokenUsage = null;
|
|
||||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
@@ -1647,17 +1646,6 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
|||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
|
|
||||||
// Extract token usage from token_count events (keep latest)
|
|
||||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
|
||||||
const info = entry.payload.info;
|
|
||||||
if (info.total_token_usage) {
|
|
||||||
tokenUsage = {
|
|
||||||
used: info.total_token_usage.total_tokens || 0,
|
|
||||||
total: info.model_context_window || 200000
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use event_msg.user_message for user-visible inputs.
|
// Use event_msg.user_message for user-visible inputs.
|
||||||
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
|
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
|
||||||
messages.push({
|
messages.push({
|
||||||
@@ -1820,11 +1808,10 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
|||||||
hasMore,
|
hasMore,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
tokenUsage
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { messages, tokenUsage };
|
return { messages };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ export const codexAdapter = {
|
|||||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||||
const tokenUsage = result.tokenUsage || null;
|
|
||||||
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const raw of rawMessages) {
|
for (const raw of rawMessages) {
|
||||||
@@ -242,7 +241,6 @@ export const codexAdapter = {
|
|||||||
hasMore,
|
hasMore,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
tokenUsage,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,14 +53,7 @@ export function normalizeMessage(raw, sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (raw.type === 'result') {
|
if (raw.type === 'result') {
|
||||||
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
return [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
||||||
if (raw.stats?.total_tokens) {
|
|
||||||
msgs.push(createNormalizedMessage({
|
|
||||||
sessionId, timestamp: ts, provider: PROVIDER,
|
|
||||||
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return msgs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.type === 'error') {
|
if (raw.type === 'error') {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
* - stream_end: (no extra fields)
|
* - stream_end: (no extra fields)
|
||||||
* - error: content
|
* - error: content
|
||||||
* - complete: (no extra fields)
|
* - complete: (no extra fields)
|
||||||
* - status: text, tokens?, canInterrupt?
|
* - status: text, canInterrupt?
|
||||||
* - permission_request: requestId, toolName, input, context?
|
* - permission_request: requestId, toolName, input, context?
|
||||||
* - permission_cancelled: requestId
|
* - permission_cancelled: requestId
|
||||||
* - session_created: newSessionId
|
* - session_created: newSessionId
|
||||||
@@ -66,7 +66,6 @@
|
|||||||
* @property {boolean} hasMore - Whether more messages exist before the current page
|
* @property {boolean} hasMore - Whether more messages exist before the current page
|
||||||
* @property {number} offset - Current offset
|
* @property {number} offset - Current offset
|
||||||
* @property {number|null} limit - Page size used
|
* @property {number|null} limit - Page size used
|
||||||
* @property {object} [tokenUsage] - Token usage data (provider-specific)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -546,7 +546,12 @@ class ResponseCollector {
|
|||||||
const parsed = JSON.parse(msg);
|
const parsed = JSON.parse(msg);
|
||||||
// Only include claude-response messages with assistant type
|
// Only include claude-response messages with assistant type
|
||||||
if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
|
if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
|
||||||
assistantMessages.push(parsed.data);
|
const assistantMessage = { ...parsed.data };
|
||||||
|
if (assistantMessage.message?.usage) {
|
||||||
|
assistantMessage.message = { ...assistantMessage.message };
|
||||||
|
delete assistantMessage.message.usage;
|
||||||
|
}
|
||||||
|
assistantMessages.push(assistantMessage);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not JSON, skip
|
// Not JSON, skip
|
||||||
@@ -556,49 +561,6 @@ class ResponseCollector {
|
|||||||
|
|
||||||
return assistantMessages;
|
return assistantMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate total tokens from all messages
|
|
||||||
*/
|
|
||||||
getTotalTokens() {
|
|
||||||
let totalInput = 0;
|
|
||||||
let totalOutput = 0;
|
|
||||||
let totalCacheRead = 0;
|
|
||||||
let totalCacheCreation = 0;
|
|
||||||
|
|
||||||
for (const msg of this.messages) {
|
|
||||||
let data = msg;
|
|
||||||
|
|
||||||
// Parse if string
|
|
||||||
if (typeof msg === 'string') {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(msg);
|
|
||||||
} catch (e) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract usage from claude-response messages
|
|
||||||
if (data && data.type === 'claude-response' && data.data) {
|
|
||||||
const msgData = data.data;
|
|
||||||
if (msgData.message && msgData.message.usage) {
|
|
||||||
const usage = msgData.message.usage;
|
|
||||||
totalInput += usage.input_tokens || 0;
|
|
||||||
totalOutput += usage.output_tokens || 0;
|
|
||||||
totalCacheRead += usage.cache_read_input_tokens || 0;
|
|
||||||
totalCacheCreation += usage.cache_creation_input_tokens || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputTokens: totalInput,
|
|
||||||
outputTokens: totalOutput,
|
|
||||||
cacheReadTokens: totalCacheRead,
|
|
||||||
cacheCreationTokens: totalCacheCreation,
|
|
||||||
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
@@ -789,13 +751,6 @@ class ResponseCollector {
|
|||||||
* success: true,
|
* success: true,
|
||||||
* sessionId: "session-123",
|
* sessionId: "session-123",
|
||||||
* messages: [...], // Assistant messages only (filtered)
|
* messages: [...], // Assistant messages only (filtered)
|
||||||
* tokens: {
|
|
||||||
* inputTokens: 150,
|
|
||||||
* outputTokens: 50,
|
|
||||||
* cacheReadTokens: 0,
|
|
||||||
* cacheCreationTokens: 0,
|
|
||||||
* totalTokens: 200
|
|
||||||
* },
|
|
||||||
* projectPath: "/path/to/project",
|
* projectPath: "/path/to/project",
|
||||||
* branch: { // Only if createBranch=true
|
* branch: { // Only if createBranch=true
|
||||||
* name: "feature/xyz",
|
* name: "feature/xyz",
|
||||||
@@ -1173,15 +1128,13 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|||||||
// Streaming mode: end the SSE stream
|
// Streaming mode: end the SSE stream
|
||||||
writer.end();
|
writer.end();
|
||||||
} else {
|
} else {
|
||||||
// Non-streaming mode: send filtered messages and token summary as JSON
|
// Non-streaming mode: send filtered messages as JSON
|
||||||
const assistantMessages = writer.getAssistantMessages();
|
const assistantMessages = writer.getAssistantMessages();
|
||||||
const tokenSummary = writer.getTotalTokens();
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
success: true,
|
success: true,
|
||||||
sessionId: writer.getSessionId(),
|
sessionId: writer.getSessionId(),
|
||||||
messages: assistantMessages,
|
messages: assistantMessages,
|
||||||
tokens: tokenSummary,
|
|
||||||
projectPath: finalProjectPath
|
projectPath: finalProjectPath
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ const builtInCommands = [
|
|||||||
namespace: 'builtin',
|
namespace: 'builtin',
|
||||||
metadata: { type: 'builtin' }
|
metadata: { type: 'builtin' }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: '/cost',
|
|
||||||
description: 'Display token usage and cost information',
|
|
||||||
namespace: 'builtin',
|
|
||||||
metadata: { type: 'builtin' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: '/memory',
|
name: '/memory',
|
||||||
description: 'Open CLAUDE.md memory file for editing',
|
description: 'Open CLAUDE.md memory file for editing',
|
||||||
@@ -209,86 +203,6 @@ Custom commands can be created in:
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
'/cost': async (args, context) => {
|
|
||||||
const tokenUsage = context?.tokenUsage || {};
|
|
||||||
const provider = context?.provider || 'claude';
|
|
||||||
const model =
|
|
||||||
context?.model ||
|
|
||||||
(provider === 'cursor'
|
|
||||||
? CURSOR_MODELS.DEFAULT
|
|
||||||
: provider === 'codex'
|
|
||||||
? CODEX_MODELS.DEFAULT
|
|
||||||
: CLAUDE_MODELS.DEFAULT);
|
|
||||||
|
|
||||||
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
|
|
||||||
const total =
|
|
||||||
Number(
|
|
||||||
tokenUsage.total ??
|
|
||||||
tokenUsage.contextWindow ??
|
|
||||||
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
|
|
||||||
) || 160000;
|
|
||||||
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
|
|
||||||
|
|
||||||
const inputTokensRaw =
|
|
||||||
Number(
|
|
||||||
tokenUsage.inputTokens ??
|
|
||||||
tokenUsage.input ??
|
|
||||||
tokenUsage.cumulativeInputTokens ??
|
|
||||||
tokenUsage.promptTokens ??
|
|
||||||
0,
|
|
||||||
) || 0;
|
|
||||||
const outputTokens =
|
|
||||||
Number(
|
|
||||||
tokenUsage.outputTokens ??
|
|
||||||
tokenUsage.output ??
|
|
||||||
tokenUsage.cumulativeOutputTokens ??
|
|
||||||
tokenUsage.completionTokens ??
|
|
||||||
0,
|
|
||||||
) || 0;
|
|
||||||
const cacheTokens =
|
|
||||||
Number(
|
|
||||||
tokenUsage.cacheReadTokens ??
|
|
||||||
tokenUsage.cacheCreationTokens ??
|
|
||||||
tokenUsage.cacheTokens ??
|
|
||||||
tokenUsage.cachedTokens ??
|
|
||||||
0,
|
|
||||||
) || 0;
|
|
||||||
|
|
||||||
// If we only have total used tokens, treat them as input for display/estimation.
|
|
||||||
const inputTokens =
|
|
||||||
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
|
|
||||||
|
|
||||||
// Rough default rates by provider (USD / 1M tokens).
|
|
||||||
const pricingByProvider = {
|
|
||||||
claude: { input: 3, output: 15 },
|
|
||||||
cursor: { input: 3, output: 15 },
|
|
||||||
codex: { input: 1.5, output: 6 },
|
|
||||||
};
|
|
||||||
const rates = pricingByProvider[provider] || pricingByProvider.claude;
|
|
||||||
|
|
||||||
const inputCost = (inputTokens / 1_000_000) * rates.input;
|
|
||||||
const outputCost = (outputTokens / 1_000_000) * rates.output;
|
|
||||||
const totalCost = inputCost + outputCost;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'builtin',
|
|
||||||
action: 'cost',
|
|
||||||
data: {
|
|
||||||
tokenUsage: {
|
|
||||||
used,
|
|
||||||
total,
|
|
||||||
percentage,
|
|
||||||
},
|
|
||||||
cost: {
|
|
||||||
input: inputCost.toFixed(4),
|
|
||||||
output: outputCost.toFixed(4),
|
|
||||||
total: totalCost.toFixed(4),
|
|
||||||
},
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
'/status': async (args, context) => {
|
'/status': async (args, context) => {
|
||||||
// Read version from package.json
|
// Read version from package.json
|
||||||
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
|
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ interface UseChatComposerStateArgs {
|
|||||||
geminiModel: string;
|
geminiModel: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canAbortSession: boolean;
|
canAbortSession: boolean;
|
||||||
tokenBudget: Record<string, unknown> | null;
|
|
||||||
sendMessage: (message: unknown) => void;
|
sendMessage: (message: unknown) => void;
|
||||||
sendByCtrlEnter?: boolean;
|
sendByCtrlEnter?: boolean;
|
||||||
onSessionActive?: (sessionId?: string | null) => void;
|
onSessionActive?: (sessionId?: string | null) => void;
|
||||||
@@ -57,7 +56,7 @@ interface UseChatComposerStateArgs {
|
|||||||
rewindMessages: (count: number) => void;
|
rewindMessages: (count: number) => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
setCanAbortSession: (canAbort: boolean) => void;
|
setCanAbortSession: (canAbort: boolean) => void;
|
||||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
setClaudeStatus: (status: { text: string; can_interrupt: boolean } | null) => void;
|
||||||
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
}
|
}
|
||||||
@@ -114,7 +113,6 @@ export function useChatComposerState({
|
|||||||
geminiModel,
|
geminiModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
onSessionActive,
|
||||||
@@ -176,12 +174,6 @@ export function useChatComposerState({
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cost': {
|
|
||||||
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
|
|
||||||
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'status': {
|
case 'status': {
|
||||||
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
|
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
|
||||||
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
|
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
|
||||||
@@ -282,7 +274,6 @@ export function useChatComposerState({
|
|||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
provider,
|
provider,
|
||||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||||
tokenUsage: tokenBudget,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await authenticatedFetch('/api/commands/execute', {
|
const response = await authenticatedFetch('/api/commands/execute', {
|
||||||
@@ -339,7 +330,6 @@ export function useChatComposerState({
|
|||||||
provider,
|
provider,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
addMessage,
|
addMessage,
|
||||||
tokenBudget,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -543,7 +533,6 @@ export function useChatComposerState({
|
|||||||
setCanAbortSession(true);
|
setCanAbortSession(true);
|
||||||
setClaudeStatus({
|
setClaudeStatus({
|
||||||
text: 'Processing',
|
text: 'Processing',
|
||||||
tokens: 0,
|
|
||||||
can_interrupt: true,
|
can_interrupt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ type LatestChatMessage = {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
tokens?: number;
|
|
||||||
canInterrupt?: boolean;
|
canInterrupt?: boolean;
|
||||||
tokenBudget?: unknown;
|
|
||||||
newSessionId?: string;
|
newSessionId?: string;
|
||||||
aborted?: boolean;
|
aborted?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -55,8 +53,7 @@ interface UseChatRealtimeHandlersArgs {
|
|||||||
setCurrentSessionId: (sessionId: string | null) => void;
|
setCurrentSessionId: (sessionId: string | null) => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
setCanAbortSession: (canAbort: boolean) => void;
|
setCanAbortSession: (canAbort: boolean) => void;
|
||||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
setClaudeStatus: (status: { text: string; can_interrupt: boolean } | null) => void;
|
||||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||||
streamBufferRef: MutableRefObject<string>;
|
streamBufferRef: MutableRefObject<string>;
|
||||||
@@ -85,7 +82,6 @@ export function useChatRealtimeHandlers({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
setTokenBudget,
|
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
streamBufferRef,
|
streamBufferRef,
|
||||||
@@ -140,7 +136,6 @@ export function useChatRealtimeHandlers({
|
|||||||
if (status) {
|
if (status) {
|
||||||
const statusInfo = {
|
const statusInfo = {
|
||||||
text: status.text || 'Working...',
|
text: status.text || 'Working...',
|
||||||
tokens: status.tokens || 0,
|
|
||||||
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
||||||
};
|
};
|
||||||
setClaudeStatus(statusInfo);
|
setClaudeStatus(statusInfo);
|
||||||
@@ -311,7 +306,7 @@ export function useChatRealtimeHandlers({
|
|||||||
});
|
});
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setCanAbortSession(true);
|
setCanAbortSession(true);
|
||||||
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
setClaudeStatus({ text: 'Waiting for permission', can_interrupt: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,12 +318,9 @@ export function useChatRealtimeHandlers({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'status': {
|
case 'status': {
|
||||||
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
if (msg.text) {
|
||||||
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
|
||||||
} else if (msg.text) {
|
|
||||||
setClaudeStatus({
|
setClaudeStatus({
|
||||||
text: msg.text,
|
text: msg.text,
|
||||||
tokens: msg.tokens || 0,
|
|
||||||
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
||||||
});
|
});
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -352,7 +344,6 @@ export function useChatRealtimeHandlers({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
setTokenBudget,
|
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
streamBufferRef,
|
streamBufferRef,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
|
||||||
import type { ChatMessage, Provider } from '../types/types';
|
import type { ChatMessage, Provider } from '../types/types';
|
||||||
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
||||||
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
||||||
@@ -108,9 +107,8 @@ export function useChatSessionState({
|
|||||||
const [totalMessages, setTotalMessages] = useState(0);
|
const [totalMessages, setTotalMessages] = useState(0);
|
||||||
const [canAbortSession, setCanAbortSession] = useState(false);
|
const [canAbortSession, setCanAbortSession] = useState(false);
|
||||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||||
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
|
||||||
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
||||||
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
const [claudeStatus, setClaudeStatus] = useState<{ text: string; can_interrupt: boolean } | null>(null);
|
||||||
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
||||||
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
||||||
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
||||||
@@ -319,7 +317,6 @@ export function useChatSessionState({
|
|||||||
messagesOffsetRef.current = 0;
|
messagesOffsetRef.current = 0;
|
||||||
setHasMoreMessages(false);
|
setHasMoreMessages(false);
|
||||||
setTotalMessages(0);
|
setTotalMessages(0);
|
||||||
setTokenBudget(null);
|
|
||||||
lastLoadedSessionKeyRef.current = null;
|
lastLoadedSessionKeyRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,7 +352,6 @@ export function useChatSessionState({
|
|||||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||||
|
|
||||||
if (sessionChanged) {
|
if (sessionChanged) {
|
||||||
setTokenBudget(null);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +379,6 @@ export function useChatSessionState({
|
|||||||
if (slot) {
|
if (slot) {
|
||||||
setHasMoreMessages(slot.hasMore);
|
setHasMoreMessages(slot.hasMore);
|
||||||
setTotalMessages(slot.total);
|
setTotalMessages(slot.total);
|
||||||
if (slot.tokenUsage) setTokenBudget(slot.tokenUsage as Record<string, unknown>);
|
|
||||||
}
|
}
|
||||||
setIsLoadingSessionMessages(false);
|
setIsLoadingSessionMessages(false);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@@ -539,31 +534,6 @@ export function useChatSessionState({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
||||||
|
|
||||||
// Token usage fetch for Claude
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
|
||||||
setTokenBudget(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sessionProvider = selectedSession.__provider || 'claude';
|
|
||||||
if (sessionProvider !== 'claude') return;
|
|
||||||
|
|
||||||
const fetchInitialTokenUsage = async () => {
|
|
||||||
try {
|
|
||||||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
|
||||||
const response = await authenticatedFetch(url);
|
|
||||||
if (response.ok) {
|
|
||||||
setTokenBudget(await response.json());
|
|
||||||
} else {
|
|
||||||
setTokenBudget(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch initial token usage:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchInitialTokenUsage();
|
|
||||||
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
|
||||||
|
|
||||||
const visibleMessages = useMemo(() => {
|
const visibleMessages = useMemo(() => {
|
||||||
if (chatMessages.length <= visibleMessageCount) return chatMessages;
|
if (chatMessages.length <= visibleMessageCount) return chatMessages;
|
||||||
return chatMessages.slice(-visibleMessageCount);
|
return chatMessages.slice(-visibleMessageCount);
|
||||||
@@ -713,8 +683,6 @@ export function useChatSessionState({
|
|||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
tokenBudget,
|
|
||||||
setTokenBudget,
|
|
||||||
visibleMessageCount,
|
visibleMessageCount,
|
||||||
visibleMessages,
|
visibleMessages,
|
||||||
loadEarlierMessages,
|
loadEarlierMessages,
|
||||||
|
|||||||
@@ -96,8 +96,6 @@ function ChatInterface({
|
|||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
tokenBudget,
|
|
||||||
setTokenBudget,
|
|
||||||
visibleMessageCount,
|
visibleMessageCount,
|
||||||
visibleMessages,
|
visibleMessages,
|
||||||
loadEarlierMessages,
|
loadEarlierMessages,
|
||||||
@@ -183,7 +181,6 @@ function ChatInterface({
|
|||||||
geminiModel,
|
geminiModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
tokenBudget,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
onSessionActive,
|
||||||
@@ -227,7 +224,6 @@ function ChatInterface({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
setTokenBudget,
|
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
streamBufferRef,
|
streamBufferRef,
|
||||||
@@ -352,7 +348,6 @@ function ChatInterface({
|
|||||||
onModeSwitch={cyclePermissionMode}
|
onModeSwitch={cyclePermissionMode}
|
||||||
thinkingMode={thinkingMode}
|
thinkingMode={thinkingMode}
|
||||||
setThinkingMode={setThinkingMode}
|
setThinkingMode={setThinkingMode}
|
||||||
tokenBudget={tokenBudget}
|
|
||||||
slashCommandsCount={slashCommandsCount}
|
slashCommandsCount={slashCommandsCount}
|
||||||
onToggleCommandMenu={handleToggleCommandMenu}
|
onToggleCommandMenu={handleToggleCommandMenu}
|
||||||
hasInput={Boolean(input.trim())}
|
hasInput={Boolean(input.trim())}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ interface ChatComposerProps {
|
|||||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||||
) => void;
|
) => void;
|
||||||
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||||
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
claudeStatus: { text: string; can_interrupt: boolean } | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onAbortSession: () => void;
|
onAbortSession: () => void;
|
||||||
provider: Provider | string;
|
provider: Provider | string;
|
||||||
@@ -49,7 +49,6 @@ interface ChatComposerProps {
|
|||||||
onModeSwitch: () => void;
|
onModeSwitch: () => void;
|
||||||
thinkingMode: string;
|
thinkingMode: string;
|
||||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||||
tokenBudget: { used?: number; total?: number } | null;
|
|
||||||
slashCommandsCount: number;
|
slashCommandsCount: number;
|
||||||
onToggleCommandMenu: () => void;
|
onToggleCommandMenu: () => void;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
@@ -106,7 +105,6 @@ export default function ChatComposer({
|
|||||||
onModeSwitch,
|
onModeSwitch,
|
||||||
thinkingMode,
|
thinkingMode,
|
||||||
setThinkingMode,
|
setThinkingMode,
|
||||||
tokenBudget,
|
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
@@ -194,7 +192,6 @@ export default function ChatComposer({
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
thinkingMode={thinkingMode}
|
thinkingMode={thinkingMode}
|
||||||
setThinkingMode={setThinkingMode}
|
setThinkingMode={setThinkingMode}
|
||||||
tokenBudget={tokenBudget}
|
|
||||||
slashCommandsCount={slashCommandsCount}
|
slashCommandsCount={slashCommandsCount}
|
||||||
onToggleCommandMenu={onToggleCommandMenu}
|
onToggleCommandMenu={onToggleCommandMenu}
|
||||||
hasInput={hasInput}
|
hasInput={hasInput}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { PermissionMode, Provider } from '../../types/types';
|
import type { PermissionMode, Provider } from '../../types/types';
|
||||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||||
import TokenUsagePie from './TokenUsagePie';
|
|
||||||
|
|
||||||
interface ChatInputControlsProps {
|
interface ChatInputControlsProps {
|
||||||
permissionMode: PermissionMode | string;
|
permissionMode: PermissionMode | string;
|
||||||
@@ -10,7 +9,6 @@ interface ChatInputControlsProps {
|
|||||||
provider: Provider | string;
|
provider: Provider | string;
|
||||||
thinkingMode: string;
|
thinkingMode: string;
|
||||||
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
|
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
|
||||||
tokenBudget: { used?: number; total?: number } | null;
|
|
||||||
slashCommandsCount: number;
|
slashCommandsCount: number;
|
||||||
onToggleCommandMenu: () => void;
|
onToggleCommandMenu: () => void;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
@@ -26,7 +24,6 @@ export default function ChatInputControls({
|
|||||||
provider,
|
provider,
|
||||||
thinkingMode,
|
thinkingMode,
|
||||||
setThinkingMode,
|
setThinkingMode,
|
||||||
tokenBudget,
|
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
@@ -78,8 +75,6 @@ export default function ChatInputControls({
|
|||||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleCommandMenu}
|
onClick={onToggleCommandMenu}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'
|
|||||||
type ClaudeStatusProps = {
|
type ClaudeStatusProps = {
|
||||||
status: {
|
status: {
|
||||||
text?: string;
|
text?: string;
|
||||||
tokens?: number;
|
|
||||||
can_interrupt?: boolean;
|
can_interrupt?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
onAbort?: () => void;
|
onAbort?: () => void;
|
||||||
@@ -126,4 +125,4 @@ export default function ClaudeStatus({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
type TokenUsagePieProps = {
|
|
||||||
used: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
|
|
||||||
// Token usage visualization component
|
|
||||||
// Only bail out on missing values or non‐positive totals; allow used===0 to render 0%
|
|
||||||
if (used == null || total == null || total <= 0) return null;
|
|
||||||
|
|
||||||
const percentage = Math.min(100, (used / total) * 100);
|
|
||||||
const radius = 10;
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const offset = circumference - (percentage / 100) * circumference;
|
|
||||||
|
|
||||||
// Color based on usage level
|
|
||||||
const getColor = () => {
|
|
||||||
if (percentage < 50) return '#3b82f6'; // blue
|
|
||||||
if (percentage < 75) return '#f59e0b'; // orange
|
|
||||||
return '#ef4444'; // red
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
|
|
||||||
{/* Background circle */}
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="text-gray-300 dark:text-gray-600"
|
|
||||||
/>
|
|
||||||
{/* Progress circle */}
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke={getColor()}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={offset}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
|
|
||||||
{percentage.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -46,9 +46,7 @@ export interface NormalizedMessage {
|
|||||||
toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null;
|
toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
tokens?: number;
|
|
||||||
canInterrupt?: boolean;
|
canInterrupt?: boolean;
|
||||||
tokenBudget?: unknown;
|
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
context?: unknown;
|
context?: unknown;
|
||||||
@@ -81,7 +79,6 @@ export interface SessionSlot {
|
|||||||
total: number;
|
total: number;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
offset: number;
|
offset: number;
|
||||||
tokenUsage: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: NormalizedMessage[] = [];
|
const EMPTY: NormalizedMessage[] = [];
|
||||||
@@ -98,7 +95,6 @@ function createEmptySlot(): SessionSlot {
|
|||||||
total: 0,
|
total: 0,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
tokenUsage: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +204,6 @@ export function useSessionStore() {
|
|||||||
slot.fetchedAt = Date.now();
|
slot.fetchedAt = Date.now();
|
||||||
slot.status = 'idle';
|
slot.status = 'idle';
|
||||||
recomputeMergedIfNeeded(slot);
|
recomputeMergedIfNeeded(slot);
|
||||||
if (data.tokenUsage) {
|
|
||||||
slot.tokenUsage = data.tokenUsage;
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(sessionId);
|
notify(sessionId);
|
||||||
return slot;
|
return slot;
|
||||||
|
|||||||
Reference in New Issue
Block a user