mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-12 08:31:29 +00:00
Compare commits
2 Commits
refactor/r
...
v1.28.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9552577e94 | ||
|
|
590dd42649 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -3,6 +3,21 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.28.1](https://github.com/siteboon/claudecodeui/compare/v1.28.0...v1.28.1) (2026-04-10)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add branding, community links, GitHub star badge, and About settings tab ([2207d05](https://github.com/siteboon/claudecodeui/commit/2207d05c1ca229214aa9c2e2c9f4d0827d421574))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* corrupted binary downloads ([#634](https://github.com/siteboon/claudecodeui/issues/634)) ([e61f8a5](https://github.com/siteboon/claudecodeui/commit/e61f8a543d63fe7c24a04b3d2186085a06dcbcdb))
|
||||||
|
* **ui:** remove mobile bottom nav, unify processing indicator, and improve tooltip behavior on mobile ([#632](https://github.com/siteboon/claudecodeui/issues/632)) ([a8dab0e](https://github.com/siteboon/claudecodeui/commit/a8dab0edcf949ae610820bae9500c433781f7c73))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* remove unused whispher transcribe logic ([#637](https://github.com/siteboon/claudecodeui/issues/637)) ([590dd42](https://github.com/siteboon/claudecodeui/commit/590dd42649424ab990353fcf59ce0965036d3d25))
|
||||||
|
|
||||||
## [1.28.0](https://github.com/siteboon/claudecodeui/compare/v1.27.1...v1.28.0) (2026-04-03)
|
## [1.28.0](https://github.com/siteboon/claudecodeui/compare/v1.27.1...v1.28.0) (2026-04-03)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.28.0",
|
"version": "1.28.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.28.0",
|
"version": "1.28.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.28.0",
|
"version": "1.28.1",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
|
|||||||
@@ -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 and assistant messages only (filtered). Content-Type: <code>application/json</code></p>
|
<p>JSON object containing session details, assistant messages only (filtered), and token usage summary. 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,10 +674,21 @@ 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,6 +274,46 @@ 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
|
||||||
@@ -617,6 +657,18 @@ 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
|
||||||
|
|||||||
337
server/index.js
337
server/index.js
@@ -1984,155 +1984,6 @@ function handleShellConnection(ws) {
|
|||||||
console.error('[ERROR] Shell WebSocket error:', error);
|
console.error('[ERROR] Shell WebSocket error:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Audio transcription endpoint
|
|
||||||
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const multer = (await import('multer')).default;
|
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
|
||||||
|
|
||||||
// Handle multipart form data
|
|
||||||
upload.single('audio')(req, res, async (err) => {
|
|
||||||
if (err) {
|
|
||||||
return res.status(400).json({ error: 'Failed to process audio file' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.file) {
|
|
||||||
return res.status(400).json({ error: 'No audio file provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.OPENAI_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create form data for OpenAI
|
|
||||||
const FormData = (await import('form-data')).default;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', req.file.buffer, {
|
|
||||||
filename: req.file.originalname,
|
|
||||||
contentType: req.file.mimetype
|
|
||||||
});
|
|
||||||
formData.append('model', 'whisper-1');
|
|
||||||
formData.append('response_format', 'json');
|
|
||||||
formData.append('language', 'en');
|
|
||||||
|
|
||||||
// Make request to OpenAI
|
|
||||||
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
...formData.getHeaders()
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
let transcribedText = data.text || '';
|
|
||||||
|
|
||||||
// Check if enhancement mode is enabled
|
|
||||||
const mode = req.body.mode || 'default';
|
|
||||||
|
|
||||||
// If no transcribed text, return empty
|
|
||||||
if (!transcribedText) {
|
|
||||||
return res.json({ text: '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If default mode, return transcribed text without enhancement
|
|
||||||
if (mode === 'default') {
|
|
||||||
return res.json({ text: transcribedText });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different enhancement modes
|
|
||||||
try {
|
|
||||||
const OpenAI = (await import('openai')).default;
|
|
||||||
const openai = new OpenAI({ apiKey });
|
|
||||||
|
|
||||||
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
|
|
||||||
|
|
||||||
switch (mode) {
|
|
||||||
case 'prompt':
|
|
||||||
systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
|
|
||||||
prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.
|
|
||||||
|
|
||||||
Your enhanced prompt should:
|
|
||||||
1. Be specific and unambiguous
|
|
||||||
2. Include relevant context and constraints
|
|
||||||
3. Specify the desired output format
|
|
||||||
4. Use clear, actionable language
|
|
||||||
5. Include examples where helpful
|
|
||||||
6. Consider edge cases and potential ambiguities
|
|
||||||
|
|
||||||
Transform this rough instruction into a well-crafted prompt:
|
|
||||||
"${transcribedText}"
|
|
||||||
|
|
||||||
Enhanced prompt:`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'vibe':
|
|
||||||
case 'instructions':
|
|
||||||
case 'architect':
|
|
||||||
systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
|
|
||||||
temperature = 0.5; // Lower temperature for more controlled output
|
|
||||||
prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.
|
|
||||||
|
|
||||||
IMPORTANT RULES:
|
|
||||||
- Format as clear, step-by-step instructions
|
|
||||||
- Add reasonable implementation details based on common patterns
|
|
||||||
- Only include details directly related to what was asked
|
|
||||||
- Do NOT add features or functionality not mentioned
|
|
||||||
- Keep the original intent and scope intact
|
|
||||||
- Use clear, actionable language an agent can follow
|
|
||||||
|
|
||||||
Transform this idea into agent-friendly instructions:
|
|
||||||
"${transcribedText}"
|
|
||||||
|
|
||||||
Agent instructions:`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// No enhancement needed
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only make GPT call if we have a prompt
|
|
||||||
if (prompt) {
|
|
||||||
const completion = await openai.chat.completions.create({
|
|
||||||
model: 'gpt-4o-mini',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemMessage },
|
|
||||||
{ role: 'user', content: prompt }
|
|
||||||
],
|
|
||||||
temperature: temperature,
|
|
||||||
max_tokens: maxTokens
|
|
||||||
});
|
|
||||||
|
|
||||||
transcribedText = completion.choices[0].message.content || transcribedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (gptError) {
|
|
||||||
console.error('GPT processing error:', gptError);
|
|
||||||
// Fall back to original transcription if GPT fails
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ text: transcribedText });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Transcription error:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Endpoint error:', error);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Image upload endpoint
|
// Image upload endpoint
|
||||||
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
|
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -2218,6 +2069,194 @@ 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,7 +129,8 @@ 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':
|
||||||
@@ -278,6 +279,12 @@ 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,6 +1618,7 @@ 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,
|
||||||
@@ -1646,6 +1647,17 @@ 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({
|
||||||
@@ -1808,10 +1820,11 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
|||||||
hasMore,
|
hasMore,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
|
tokenUsage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { messages };
|
return { messages, tokenUsage };
|
||||||
|
|
||||||
} 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,6 +214,7 @@ 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) {
|
||||||
@@ -241,6 +242,7 @@ export const codexAdapter = {
|
|||||||
hasMore,
|
hasMore,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
|
tokenUsage,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,14 @@ export function normalizeMessage(raw, sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (raw.type === 'result') {
|
if (raw.type === 'result') {
|
||||||
return [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
const msgs = [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, canInterrupt?
|
* - status: text, tokens?, 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,6 +66,7 @@
|
|||||||
* @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,12 +546,7 @@ 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') {
|
||||||
const assistantMessage = { ...parsed.data };
|
assistantMessages.push(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
|
||||||
@@ -561,6 +556,49 @@ 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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
@@ -751,6 +789,13 @@ 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",
|
||||||
@@ -1128,13 +1173,15 @@ 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 as JSON
|
// Non-streaming mode: send filtered messages and token summary 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,6 +97,12 @@ 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',
|
||||||
@@ -203,6 +209,86 @@ 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,6 +42,7 @@ 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;
|
||||||
@@ -56,7 +57,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; can_interrupt: boolean } | null) => void;
|
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||||
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,7 @@ export function useChatComposerState({
|
|||||||
geminiModel,
|
geminiModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
onSessionActive,
|
||||||
@@ -174,6 +176,12 @@ 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() });
|
||||||
@@ -274,6 +282,7 @@ 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', {
|
||||||
@@ -330,6 +339,7 @@ export function useChatComposerState({
|
|||||||
provider,
|
provider,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
addMessage,
|
addMessage,
|
||||||
|
tokenBudget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -533,6 +543,7 @@ export function useChatComposerState({
|
|||||||
setCanAbortSession(true);
|
setCanAbortSession(true);
|
||||||
setClaudeStatus({
|
setClaudeStatus({
|
||||||
text: 'Processing',
|
text: 'Processing',
|
||||||
|
tokens: 0,
|
||||||
can_interrupt: true,
|
can_interrupt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -867,30 +878,6 @@ export function useChatComposerState({
|
|||||||
});
|
});
|
||||||
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
|
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
|
||||||
|
|
||||||
const handleTranscript = useCallback((text: string) => {
|
|
||||||
if (!text.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInput((previousInput) => {
|
|
||||||
const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;
|
|
||||||
inputValueRef.current = newInput;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!textareaRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
textareaRef.current.style.height = 'auto';
|
|
||||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
|
||||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
|
||||||
setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return newInput;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleGrantToolPermission = useCallback(
|
const handleGrantToolPermission = useCallback(
|
||||||
(suggestion: { entry: string; toolName: string }) => {
|
(suggestion: { entry: string; toolName: string }) => {
|
||||||
if (!suggestion || provider !== 'claude') {
|
if (!suggestion || provider !== 'claude') {
|
||||||
@@ -983,7 +970,6 @@ export function useChatComposerState({
|
|||||||
syncInputOverlayScroll,
|
syncInputOverlayScroll,
|
||||||
handleClearInput,
|
handleClearInput,
|
||||||
handleAbortSession,
|
handleAbortSession,
|
||||||
handleTranscript,
|
|
||||||
handlePermissionDecision,
|
handlePermissionDecision,
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
handleInputFocusChange,
|
handleInputFocusChange,
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ 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;
|
||||||
@@ -53,7 +55,8 @@ 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; can_interrupt: boolean } | null) => void;
|
setClaudeStatus: (status: { text: string; tokens: number; 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>;
|
||||||
@@ -82,6 +85,7 @@ export function useChatRealtimeHandlers({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
streamBufferRef,
|
streamBufferRef,
|
||||||
@@ -136,6 +140,7 @@ 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);
|
||||||
@@ -306,7 +311,7 @@ export function useChatRealtimeHandlers({
|
|||||||
});
|
});
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setCanAbortSession(true);
|
setCanAbortSession(true);
|
||||||
setClaudeStatus({ text: 'Waiting for permission', can_interrupt: true });
|
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +323,12 @@ export function useChatRealtimeHandlers({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'status': {
|
case 'status': {
|
||||||
if (msg.text) {
|
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
||||||
|
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);
|
||||||
@@ -344,6 +352,7 @@ export function useChatRealtimeHandlers({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
streamBufferRef,
|
streamBufferRef,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
@@ -107,8 +108,9 @@ 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; can_interrupt: boolean } | null>(null);
|
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; 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);
|
||||||
@@ -317,6 +319,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -352,6 +355,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +383,7 @@ 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(() => {
|
||||||
@@ -534,6 +539,31 @@ 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);
|
||||||
@@ -683,6 +713,8 @@ export function useChatSessionState({
|
|||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
|
tokenBudget,
|
||||||
|
setTokenBudget,
|
||||||
visibleMessageCount,
|
visibleMessageCount,
|
||||||
visibleMessages,
|
visibleMessages,
|
||||||
loadEarlierMessages,
|
loadEarlierMessages,
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ function ChatInterface({
|
|||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
|
tokenBudget,
|
||||||
|
setTokenBudget,
|
||||||
visibleMessageCount,
|
visibleMessageCount,
|
||||||
visibleMessages,
|
visibleMessages,
|
||||||
loadEarlierMessages,
|
loadEarlierMessages,
|
||||||
@@ -163,7 +165,6 @@ function ChatInterface({
|
|||||||
syncInputOverlayScroll,
|
syncInputOverlayScroll,
|
||||||
handleClearInput,
|
handleClearInput,
|
||||||
handleAbortSession,
|
handleAbortSession,
|
||||||
handleTranscript,
|
|
||||||
handlePermissionDecision,
|
handlePermissionDecision,
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
handleInputFocusChange,
|
handleInputFocusChange,
|
||||||
@@ -181,6 +182,7 @@ function ChatInterface({
|
|||||||
geminiModel,
|
geminiModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
|
tokenBudget,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onSessionActive,
|
onSessionActive,
|
||||||
@@ -224,6 +226,7 @@ function ChatInterface({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
|
setTokenBudget,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
streamBufferRef,
|
streamBufferRef,
|
||||||
@@ -348,6 +351,7 @@ 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())}
|
||||||
@@ -402,7 +406,6 @@ function ChatInterface({
|
|||||||
})}
|
})}
|
||||||
isTextareaExpanded={isTextareaExpanded}
|
isTextareaExpanded={isTextareaExpanded}
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
onTranscript={handleTranscript}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
SetStateAction,
|
SetStateAction,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import MicButton from '../../../mic-button/view/MicButton';
|
|
||||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||||
import CommandMenu from './CommandMenu';
|
import CommandMenu from './CommandMenu';
|
||||||
import ClaudeStatus from './ClaudeStatus';
|
import ClaudeStatus from './ClaudeStatus';
|
||||||
@@ -41,7 +40,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; can_interrupt: boolean } | null;
|
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onAbortSession: () => void;
|
onAbortSession: () => void;
|
||||||
provider: Provider | string;
|
provider: Provider | string;
|
||||||
@@ -49,6 +48,7 @@ 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;
|
||||||
@@ -90,7 +90,6 @@ interface ChatComposerProps {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
isTextareaExpanded: boolean;
|
isTextareaExpanded: boolean;
|
||||||
sendByCtrlEnter?: boolean;
|
sendByCtrlEnter?: boolean;
|
||||||
onTranscript: (text: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatComposer({
|
export default function ChatComposer({
|
||||||
@@ -105,6 +104,7 @@ export default function ChatComposer({
|
|||||||
onModeSwitch,
|
onModeSwitch,
|
||||||
thinkingMode,
|
thinkingMode,
|
||||||
setThinkingMode,
|
setThinkingMode,
|
||||||
|
tokenBudget,
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
@@ -146,7 +146,6 @@ export default function ChatComposer({
|
|||||||
placeholder,
|
placeholder,
|
||||||
isTextareaExpanded,
|
isTextareaExpanded,
|
||||||
sendByCtrlEnter,
|
sendByCtrlEnter,
|
||||||
onTranscript,
|
|
||||||
}: ChatComposerProps) {
|
}: ChatComposerProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||||
@@ -192,6 +191,7 @@ 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}
|
||||||
@@ -318,10 +318,6 @@ export default function ChatComposer({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="absolute right-16 top-1/2 -translate-y-1/2 transform sm:right-16" style={{ display: 'none' }}>
|
|
||||||
<MicButton onTranscript={onTranscript} className="h-10 w-10 sm:h-10 sm:w-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!input.trim() || isLoading}
|
disabled={!input.trim() || isLoading}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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;
|
||||||
@@ -9,6 +10,7 @@ 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;
|
||||||
@@ -24,6 +26,7 @@ export default function ChatInputControls({
|
|||||||
provider,
|
provider,
|
||||||
thinkingMode,
|
thinkingMode,
|
||||||
setThinkingMode,
|
setThinkingMode,
|
||||||
|
tokenBudget,
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
@@ -75,6 +78,8 @@ 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,6 +6,7 @@ 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;
|
||||||
|
|||||||
54
src/components/chat/view/subcomponents/TokenUsagePie.tsx
Normal file
54
src/components/chat/view/subcomponents/TokenUsagePie.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
|
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import MicButton from '../../../mic-button/view/MicButton';
|
|
||||||
import type { ConfirmationRequest } from '../../types/types';
|
import type { ConfirmationRequest } from '../../types/types';
|
||||||
|
|
||||||
// Persists commit messages across unmount/remount, keyed by project path
|
// Persists commit messages across unmount/remount, keyed by project path
|
||||||
@@ -147,13 +146,6 @@ export default function CommitComposer({
|
|||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div style={{ display: 'none' }}>
|
|
||||||
<MicButton
|
|
||||||
onTranscript={(transcript) => setCommitMessage(transcript)}
|
|
||||||
mode="default"
|
|
||||||
className="p-1.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { MicButtonState } from '../types/types';
|
|
||||||
|
|
||||||
export const MIC_BUTTON_STATES = {
|
|
||||||
IDLE: 'idle',
|
|
||||||
RECORDING: 'recording',
|
|
||||||
TRANSCRIBING: 'transcribing',
|
|
||||||
PROCESSING: 'processing',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const MIC_TAP_DEBOUNCE_MS = 300;
|
|
||||||
export const PROCESSING_STATE_DELAY_MS = 2000;
|
|
||||||
|
|
||||||
export const DEFAULT_WHISPER_MODE = 'default';
|
|
||||||
|
|
||||||
// Modes that use post-transcription enhancement on the backend.
|
|
||||||
export const ENHANCEMENT_WHISPER_MODES = new Set([
|
|
||||||
'prompt',
|
|
||||||
'vibe',
|
|
||||||
'instructions',
|
|
||||||
'architect',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const BUTTON_BACKGROUND_BY_STATE: Record<MicButtonState, string> = {
|
|
||||||
idle: '#374151',
|
|
||||||
recording: '#ef4444',
|
|
||||||
transcribing: '#3b82f6',
|
|
||||||
processing: '#a855f7',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MIC_ERROR_BY_NAME = {
|
|
||||||
NotAllowedError: 'Microphone access denied. Please allow microphone permissions.',
|
|
||||||
NotFoundError: 'No microphone found. Please check your audio devices.',
|
|
||||||
NotSupportedError: 'Microphone not supported by this browser.',
|
|
||||||
NotReadableError: 'Microphone is being used by another application.',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const MIC_NOT_AVAILABLE_ERROR =
|
|
||||||
'Microphone access not available. Please use HTTPS or a supported browser.';
|
|
||||||
|
|
||||||
export const MIC_NOT_SUPPORTED_ERROR =
|
|
||||||
'Microphone not supported. Please use HTTPS or a modern browser.';
|
|
||||||
|
|
||||||
export const MIC_SECURE_CONTEXT_ERROR =
|
|
||||||
'Microphone requires HTTPS. Please use a secure connection.';
|
|
||||||
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { api } from '../../../utils/api';
|
|
||||||
|
|
||||||
type WhisperStatus = 'transcribing';
|
|
||||||
|
|
||||||
type WhisperResponse = {
|
|
||||||
text?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function transcribeWithWhisper(
|
|
||||||
audioBlob: Blob,
|
|
||||||
onStatusChange?: (status: WhisperStatus) => void,
|
|
||||||
): Promise<string> {
|
|
||||||
const formData = new FormData();
|
|
||||||
const fileName = `recording_${Date.now()}.webm`;
|
|
||||||
const file = new File([audioBlob], fileName, { type: audioBlob.type });
|
|
||||||
|
|
||||||
formData.append('audio', file);
|
|
||||||
|
|
||||||
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
|
|
||||||
formData.append('mode', whisperMode);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Keep existing status callback behavior.
|
|
||||||
if (onStatusChange) {
|
|
||||||
onStatusChange('transcribing');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = (await api.transcribe(formData)) as Response;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = (await response.json().catch(() => ({}))) as WhisperResponse;
|
|
||||||
throw new Error(
|
|
||||||
errorData.error ||
|
|
||||||
`Transcription error: ${response.status} ${response.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as WhisperResponse;
|
|
||||||
return data.text || '';
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof Error
|
|
||||||
&& error.name === 'TypeError'
|
|
||||||
&& error.message.includes('fetch')
|
|
||||||
) {
|
|
||||||
throw new Error('Cannot connect to server. Please ensure the backend is running.');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import type { MouseEvent } from 'react';
|
|
||||||
import { transcribeWithWhisper } from '../data/whisper';
|
|
||||||
import {
|
|
||||||
DEFAULT_WHISPER_MODE,
|
|
||||||
ENHANCEMENT_WHISPER_MODES,
|
|
||||||
MIC_BUTTON_STATES,
|
|
||||||
MIC_ERROR_BY_NAME,
|
|
||||||
MIC_NOT_AVAILABLE_ERROR,
|
|
||||||
MIC_NOT_SUPPORTED_ERROR,
|
|
||||||
MIC_SECURE_CONTEXT_ERROR,
|
|
||||||
MIC_TAP_DEBOUNCE_MS,
|
|
||||||
PROCESSING_STATE_DELAY_MS,
|
|
||||||
} from '../constants/constants';
|
|
||||||
import type { MicButtonState } from '../types/types';
|
|
||||||
|
|
||||||
type UseMicButtonControllerArgs = {
|
|
||||||
onTranscript?: (transcript: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UseMicButtonControllerResult = {
|
|
||||||
state: MicButtonState;
|
|
||||||
error: string | null;
|
|
||||||
isSupported: boolean;
|
|
||||||
handleButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRecordingErrorMessage = (error: unknown): string => {
|
|
||||||
if (error instanceof Error && error.message.includes('HTTPS')) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof DOMException) {
|
|
||||||
return MIC_ERROR_BY_NAME[error.name as keyof typeof MIC_ERROR_BY_NAME] || 'Microphone access failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Microphone access failed';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRecorderMimeType = (): string => (
|
|
||||||
MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'
|
|
||||||
);
|
|
||||||
|
|
||||||
export function useMicButtonController({
|
|
||||||
onTranscript,
|
|
||||||
}: UseMicButtonControllerArgs): UseMicButtonControllerResult {
|
|
||||||
const [state, setState] = useState<MicButtonState>(MIC_BUTTON_STATES.IDLE);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isSupported, setIsSupported] = useState(true);
|
|
||||||
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
|
||||||
const chunksRef = useRef<BlobPart[]>([]);
|
|
||||||
const lastTapRef = useRef(0);
|
|
||||||
const processingTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const clearProcessingTimer = (): void => {
|
|
||||||
if (processingTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(processingTimerRef.current);
|
|
||||||
processingTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopStreamTracks = (): void => {
|
|
||||||
if (!streamRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
|
||||||
streamRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStopRecording = async (mimeType: string): Promise<void> => {
|
|
||||||
const audioBlob = new Blob(chunksRef.current, { type: mimeType });
|
|
||||||
|
|
||||||
// Release the microphone immediately once recording ends.
|
|
||||||
stopStreamTracks();
|
|
||||||
setState(MIC_BUTTON_STATES.TRANSCRIBING);
|
|
||||||
|
|
||||||
const whisperMode = window.localStorage.getItem('whisperMode') || DEFAULT_WHISPER_MODE;
|
|
||||||
const shouldShowProcessingState = ENHANCEMENT_WHISPER_MODES.has(whisperMode);
|
|
||||||
|
|
||||||
if (shouldShowProcessingState) {
|
|
||||||
processingTimerRef.current = window.setTimeout(() => {
|
|
||||||
setState(MIC_BUTTON_STATES.PROCESSING);
|
|
||||||
}, PROCESSING_STATE_DELAY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transcript = await transcribeWithWhisper(audioBlob);
|
|
||||||
if (transcript && onTranscript) {
|
|
||||||
onTranscript(transcript);
|
|
||||||
}
|
|
||||||
} catch (transcriptionError) {
|
|
||||||
const message = transcriptionError instanceof Error ? transcriptionError.message : 'Transcription error';
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
clearProcessingTimer();
|
|
||||||
setState(MIC_BUTTON_STATES.IDLE);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startRecording = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
chunksRef.current = [];
|
|
||||||
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
||||||
throw new Error(MIC_NOT_AVAILABLE_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
||||||
streamRef.current = stream;
|
|
||||||
|
|
||||||
const mimeType = getRecorderMimeType();
|
|
||||||
const recorder = new MediaRecorder(stream, { mimeType });
|
|
||||||
mediaRecorderRef.current = recorder;
|
|
||||||
|
|
||||||
recorder.ondataavailable = (event: BlobEvent) => {
|
|
||||||
if (event.data.size > 0) {
|
|
||||||
chunksRef.current.push(event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
recorder.onstop = () => {
|
|
||||||
void handleStopRecording(mimeType);
|
|
||||||
};
|
|
||||||
|
|
||||||
recorder.start();
|
|
||||||
setState(MIC_BUTTON_STATES.RECORDING);
|
|
||||||
} catch (recordingError) {
|
|
||||||
stopStreamTracks();
|
|
||||||
setError(getRecordingErrorMessage(recordingError));
|
|
||||||
setState(MIC_BUTTON_STATES.IDLE);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopRecording = (): void => {
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
|
||||||
mediaRecorderRef.current.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stopStreamTracks();
|
|
||||||
setState(MIC_BUTTON_STATES.IDLE);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleButtonClick = (event?: MouseEvent<HTMLButtonElement>): void => {
|
|
||||||
if (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSupported) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile tap handling can trigger duplicate click events in quick succession.
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastTapRef.current < MIC_TAP_DEBOUNCE_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastTapRef.current = now;
|
|
||||||
|
|
||||||
if (state === MIC_BUTTON_STATES.IDLE) {
|
|
||||||
void startRecording();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === MIC_BUTTON_STATES.RECORDING) {
|
|
||||||
stopRecording();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// getUserMedia needs both browser support and a secure context.
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
||||||
setIsSupported(false);
|
|
||||||
setError(MIC_NOT_SUPPORTED_ERROR);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
|
||||||
setIsSupported(false);
|
|
||||||
setError(MIC_SECURE_CONTEXT_ERROR);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSupported(true);
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
clearProcessingTimer();
|
|
||||||
stopStreamTracks();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
error,
|
|
||||||
isSupported,
|
|
||||||
handleButtonClick,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export type MicButtonState = 'idle' | 'recording' | 'transcribing' | 'processing';
|
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { useMicButtonController } from '../hooks/useMicButtonController';
|
|
||||||
import MicButtonView from './MicButtonView';
|
|
||||||
|
|
||||||
type MicButtonProps = {
|
|
||||||
onTranscript?: (transcript: string) => void;
|
|
||||||
className?: string;
|
|
||||||
mode?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MicButton({
|
|
||||||
onTranscript,
|
|
||||||
className = '',
|
|
||||||
mode: _mode,
|
|
||||||
}: MicButtonProps) {
|
|
||||||
const { state, error, isSupported, handleButtonClick } = useMicButtonController({
|
|
||||||
onTranscript,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep `mode` in the public props for backwards compatibility.
|
|
||||||
void _mode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MicButtonView
|
|
||||||
state={state}
|
|
||||||
error={error}
|
|
||||||
isSupported={isSupported}
|
|
||||||
className={className}
|
|
||||||
onButtonClick={handleButtonClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { Brain, Loader2, Mic } from 'lucide-react';
|
|
||||||
import type { MouseEvent, ReactElement } from 'react';
|
|
||||||
import { BUTTON_BACKGROUND_BY_STATE, MIC_BUTTON_STATES } from '../constants/constants';
|
|
||||||
import type { MicButtonState } from '../types/types';
|
|
||||||
|
|
||||||
type MicButtonViewProps = {
|
|
||||||
state: MicButtonState;
|
|
||||||
error: string | null;
|
|
||||||
isSupported: boolean;
|
|
||||||
className: string;
|
|
||||||
onButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonIcon = (state: MicButtonState, isSupported: boolean): ReactElement => {
|
|
||||||
if (!isSupported) {
|
|
||||||
return <Mic className="h-5 w-5" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === MIC_BUTTON_STATES.TRANSCRIBING) {
|
|
||||||
return <Loader2 className="h-5 w-5 animate-spin" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === MIC_BUTTON_STATES.PROCESSING) {
|
|
||||||
return <Brain className="h-5 w-5 animate-pulse" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === MIC_BUTTON_STATES.RECORDING) {
|
|
||||||
return <Mic className="h-5 w-5 text-white" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Mic className="h-5 w-5" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MicButtonView({
|
|
||||||
state,
|
|
||||||
error,
|
|
||||||
isSupported,
|
|
||||||
className,
|
|
||||||
onButtonClick,
|
|
||||||
}: MicButtonViewProps) {
|
|
||||||
const isDisabled = !isSupported || state === MIC_BUTTON_STATES.TRANSCRIBING || state === MIC_BUTTON_STATES.PROCESSING;
|
|
||||||
const icon = getButtonIcon(state, isSupported);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={{ backgroundColor: BUTTON_BACKGROUND_BY_STATE[state] }}
|
|
||||||
className={`
|
|
||||||
touch-action-manipulation flex h-12
|
|
||||||
w-12 items-center justify-center
|
|
||||||
rounded-full text-white transition-all
|
|
||||||
duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
||||||
focus:ring-offset-2
|
|
||||||
dark:ring-offset-gray-800
|
|
||||||
${isDisabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
|
|
||||||
${state === MIC_BUTTON_STATES.RECORDING ? 'animate-pulse' : ''}
|
|
||||||
hover:opacity-90
|
|
||||||
${className}
|
|
||||||
`}
|
|
||||||
onClick={onButtonClick}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="animate-fade-in absolute left-1/2 top-full z-10 mt-2
|
|
||||||
-translate-x-1/2 transform whitespace-nowrap rounded bg-red-500 px-2 py-1 text-xs
|
|
||||||
text-white"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state === MIC_BUTTON_STATES.RECORDING && (
|
|
||||||
<div className="pointer-events-none absolute -inset-1 animate-ping rounded-full border-2 border-red-500" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state === MIC_BUTTON_STATES.PROCESSING && (
|
|
||||||
<div className="pointer-events-none absolute -inset-1 animate-ping rounded-full border-2 border-purple-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,21 +2,12 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
Brain,
|
Brain,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
|
||||||
Languages,
|
Languages,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Mic,
|
|
||||||
Sparkles,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type {
|
import type { PreferenceToggleItem } from './types';
|
||||||
PreferenceToggleItem,
|
|
||||||
WhisperMode,
|
|
||||||
WhisperOption,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
|
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
|
||||||
export const WHISPER_MODE_STORAGE_KEY = 'whisperMode';
|
|
||||||
export const WHISPER_MODE_CHANGED_EVENT = 'whisperModeChanged';
|
|
||||||
|
|
||||||
export const DEFAULT_HANDLE_POSITION = 50;
|
export const DEFAULT_HANDLE_POSITION = 50;
|
||||||
export const HANDLE_POSITION_MIN = 10;
|
export const HANDLE_POSITION_MIN = 10;
|
||||||
@@ -64,30 +55,3 @@ export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
|
|||||||
icon: Languages,
|
icon: Languages,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const WHISPER_OPTIONS: WhisperOption[] = [
|
|
||||||
{
|
|
||||||
value: 'default',
|
|
||||||
titleKey: 'quickSettings.whisper.modes.default',
|
|
||||||
descriptionKey: 'quickSettings.whisper.modes.defaultDescription',
|
|
||||||
icon: Mic,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'prompt',
|
|
||||||
titleKey: 'quickSettings.whisper.modes.prompt',
|
|
||||||
descriptionKey: 'quickSettings.whisper.modes.promptDescription',
|
|
||||||
icon: Sparkles,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'vibe',
|
|
||||||
titleKey: 'quickSettings.whisper.modes.vibe',
|
|
||||||
descriptionKey: 'quickSettings.whisper.modes.vibeDescription',
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const VIBE_MODE_ALIASES: WhisperMode[] = [
|
|
||||||
'vibe',
|
|
||||||
'instructions',
|
|
||||||
'architect',
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { useCallback, useState } from 'react';
|
|
||||||
import {
|
|
||||||
VIBE_MODE_ALIASES,
|
|
||||||
WHISPER_MODE_CHANGED_EVENT,
|
|
||||||
WHISPER_MODE_STORAGE_KEY,
|
|
||||||
} from '../constants';
|
|
||||||
import type { WhisperMode, WhisperOptionValue } from '../types';
|
|
||||||
|
|
||||||
const ALL_VALID_MODES: WhisperMode[] = [
|
|
||||||
'default',
|
|
||||||
'prompt',
|
|
||||||
'vibe',
|
|
||||||
'instructions',
|
|
||||||
'architect',
|
|
||||||
];
|
|
||||||
|
|
||||||
const isWhisperMode = (value: string): value is WhisperMode => (
|
|
||||||
ALL_VALID_MODES.includes(value as WhisperMode)
|
|
||||||
);
|
|
||||||
|
|
||||||
const readStoredMode = (): WhisperMode => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedValue = localStorage.getItem(WHISPER_MODE_STORAGE_KEY);
|
|
||||||
if (!storedValue) {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
return isWhisperMode(storedValue) ? storedValue : 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useWhisperMode() {
|
|
||||||
const [whisperMode, setWhisperModeState] = useState<WhisperMode>(readStoredMode);
|
|
||||||
|
|
||||||
const setWhisperMode = useCallback((value: WhisperOptionValue) => {
|
|
||||||
setWhisperModeState(value);
|
|
||||||
localStorage.setItem(WHISPER_MODE_STORAGE_KEY, value);
|
|
||||||
window.dispatchEvent(new Event(WHISPER_MODE_CHANGED_EVENT));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isOptionSelected = useCallback(
|
|
||||||
(value: WhisperOptionValue) => {
|
|
||||||
if (value === 'vibe') {
|
|
||||||
return VIBE_MODE_ALIASES.includes(whisperMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return whisperMode === value;
|
|
||||||
},
|
|
||||||
[whisperMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
whisperMode,
|
|
||||||
setWhisperMode,
|
|
||||||
isOptionSelected,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -16,20 +16,4 @@ export type PreferenceToggleItem = {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WhisperMode =
|
|
||||||
| 'default'
|
|
||||||
| 'prompt'
|
|
||||||
| 'vibe'
|
|
||||||
| 'instructions'
|
|
||||||
| 'architect';
|
|
||||||
|
|
||||||
export type WhisperOptionValue = 'default' | 'prompt' | 'vibe';
|
|
||||||
|
|
||||||
export type WhisperOption = {
|
|
||||||
value: WhisperOptionValue;
|
|
||||||
titleKey: string;
|
|
||||||
descriptionKey: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QuickSettingsHandleStyle = CSSProperties;
|
export type QuickSettingsHandleStyle = CSSProperties;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import QuickSettingsSection from './QuickSettingsSection';
|
import QuickSettingsSection from './QuickSettingsSection';
|
||||||
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
|
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
|
||||||
import QuickSettingsWhisperSection from './QuickSettingsWhisperSection';
|
|
||||||
|
|
||||||
type QuickSettingsContentProps = {
|
type QuickSettingsContentProps = {
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
@@ -73,8 +72,6 @@ export default function QuickSettingsContent({
|
|||||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||||
</p>
|
</p>
|
||||||
</QuickSettingsSection>
|
</QuickSettingsSection>
|
||||||
|
|
||||||
<QuickSettingsWhisperSection />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { TOGGLE_ROW_CLASS, WHISPER_OPTIONS } from '../constants';
|
|
||||||
import { useWhisperMode } from '../hooks/useWhisperMode';
|
|
||||||
import QuickSettingsSection from './QuickSettingsSection';
|
|
||||||
|
|
||||||
export default function QuickSettingsWhisperSection() {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
const { setWhisperMode, isOptionSelected } = useWhisperMode();
|
|
||||||
|
|
||||||
return (
|
|
||||||
// This section stays hidden intentionally until dictation modes are reintroduced.
|
|
||||||
<QuickSettingsSection
|
|
||||||
title={t('quickSettings.sections.whisperDictation')}
|
|
||||||
className="hidden"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{WHISPER_OPTIONS.map(({ value, icon: Icon, titleKey, descriptionKey }) => (
|
|
||||||
<label
|
|
||||||
key={value}
|
|
||||||
className={`${TOGGLE_ROW_CLASS} flex items-start`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="whisperMode"
|
|
||||||
value={value}
|
|
||||||
checked={isOptionSelected(value)}
|
|
||||||
onChange={() => setWhisperMode(value)}
|
|
||||||
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-blue-500 dark:checked:bg-blue-600 dark:focus:ring-blue-400"
|
|
||||||
/>
|
|
||||||
<div className="ml-3 flex-1">
|
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
{t(titleKey)}
|
|
||||||
</span>
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t(descriptionKey)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</QuickSettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -55,8 +55,7 @@
|
|||||||
"appearance": "Darstellung",
|
"appearance": "Darstellung",
|
||||||
"toolDisplay": "Werkzeuganzeige",
|
"toolDisplay": "Werkzeuganzeige",
|
||||||
"viewOptions": "Anzeigeoptionen",
|
"viewOptions": "Anzeigeoptionen",
|
||||||
"inputSettings": "Eingabeeinstellungen",
|
"inputSettings": "Eingabeeinstellungen"
|
||||||
"whisperDictation": "Whisper-Diktat"
|
|
||||||
},
|
},
|
||||||
"darkMode": "Darkmode",
|
"darkMode": "Darkmode",
|
||||||
"autoExpandTools": "Werkzeuge automatisch erweitern",
|
"autoExpandTools": "Werkzeuge automatisch erweitern",
|
||||||
@@ -71,16 +70,6 @@
|
|||||||
"openPanel": "Einstellungspanel öffnen",
|
"openPanel": "Einstellungspanel öffnen",
|
||||||
"draggingStatus": "Wird gezogen...",
|
"draggingStatus": "Wird gezogen...",
|
||||||
"toggleAndMove": "Klicken zum Umschalten, ziehen zum Verschieben"
|
"toggleAndMove": "Klicken zum Umschalten, ziehen zum Verschieben"
|
||||||
},
|
|
||||||
"whisper": {
|
|
||||||
"modes": {
|
|
||||||
"default": "Standardmodus",
|
|
||||||
"defaultDescription": "Direkte Transkription deiner Sprache",
|
|
||||||
"prompt": "Prompt-Verbesserung",
|
|
||||||
"promptDescription": "Rohe Ideen in klare, detaillierte KI-Prompts umwandeln",
|
|
||||||
"vibe": "Vibe-Modus",
|
|
||||||
"vibeDescription": "Ideen als klare Agentenanweisungen mit Details formatieren"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"terminalShortcuts": {
|
"terminalShortcuts": {
|
||||||
|
|||||||
@@ -55,8 +55,7 @@
|
|||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"toolDisplay": "Tool Display",
|
"toolDisplay": "Tool Display",
|
||||||
"viewOptions": "View Options",
|
"viewOptions": "View Options",
|
||||||
"inputSettings": "Input Settings",
|
"inputSettings": "Input Settings"
|
||||||
"whisperDictation": "Whisper Dictation"
|
|
||||||
},
|
},
|
||||||
"darkMode": "Dark Mode",
|
"darkMode": "Dark Mode",
|
||||||
"autoExpandTools": "Auto-expand tools",
|
"autoExpandTools": "Auto-expand tools",
|
||||||
@@ -71,16 +70,6 @@
|
|||||||
"openPanel": "Open settings panel",
|
"openPanel": "Open settings panel",
|
||||||
"draggingStatus": "Dragging...",
|
"draggingStatus": "Dragging...",
|
||||||
"toggleAndMove": "Click to toggle, drag to move"
|
"toggleAndMove": "Click to toggle, drag to move"
|
||||||
},
|
|
||||||
"whisper": {
|
|
||||||
"modes": {
|
|
||||||
"default": "Default Mode",
|
|
||||||
"defaultDescription": "Direct transcription of your speech",
|
|
||||||
"prompt": "Prompt Enhancement",
|
|
||||||
"promptDescription": "Transform rough ideas into clear, detailed AI prompts",
|
|
||||||
"vibe": "Vibe Mode",
|
|
||||||
"vibeDescription": "Format ideas as clear agent instructions with details"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"terminalShortcuts": {
|
"terminalShortcuts": {
|
||||||
|
|||||||
@@ -55,8 +55,7 @@
|
|||||||
"appearance": "外観",
|
"appearance": "外観",
|
||||||
"toolDisplay": "ツール表示",
|
"toolDisplay": "ツール表示",
|
||||||
"viewOptions": "表示オプション",
|
"viewOptions": "表示オプション",
|
||||||
"inputSettings": "入力設定",
|
"inputSettings": "入力設定"
|
||||||
"whisperDictation": "Whisper音声入力"
|
|
||||||
},
|
},
|
||||||
"darkMode": "ダークモード",
|
"darkMode": "ダークモード",
|
||||||
"autoExpandTools": "ツールを自動展開",
|
"autoExpandTools": "ツールを自動展開",
|
||||||
@@ -71,16 +70,6 @@
|
|||||||
"openPanel": "設定パネルを開く",
|
"openPanel": "設定パネルを開く",
|
||||||
"draggingStatus": "ドラッグ中...",
|
"draggingStatus": "ドラッグ中...",
|
||||||
"toggleAndMove": "クリックで切替、ドラッグで移動"
|
"toggleAndMove": "クリックで切替、ドラッグで移動"
|
||||||
},
|
|
||||||
"whisper": {
|
|
||||||
"modes": {
|
|
||||||
"default": "標準モード",
|
|
||||||
"defaultDescription": "音声をそのまま文字起こしします",
|
|
||||||
"prompt": "プロンプト強化",
|
|
||||||
"promptDescription": "ラフなアイデアを明確で詳細なAIプロンプトに変換します",
|
|
||||||
"vibe": "バイブモード",
|
|
||||||
"vibeDescription": "アイデアを明確なエージェント指示に整形します"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"terminalShortcuts": {
|
"terminalShortcuts": {
|
||||||
|
|||||||
@@ -55,8 +55,7 @@
|
|||||||
"appearance": "외관",
|
"appearance": "외관",
|
||||||
"toolDisplay": "도구 표시",
|
"toolDisplay": "도구 표시",
|
||||||
"viewOptions": "보기 옵션",
|
"viewOptions": "보기 옵션",
|
||||||
"inputSettings": "입력 설정",
|
"inputSettings": "입력 설정"
|
||||||
"whisperDictation": "Whisper 음성 인식"
|
|
||||||
},
|
},
|
||||||
"darkMode": "다크 모드",
|
"darkMode": "다크 모드",
|
||||||
"autoExpandTools": "도구 자동 펼치기",
|
"autoExpandTools": "도구 자동 펼치기",
|
||||||
@@ -71,16 +70,6 @@
|
|||||||
"openPanel": "설정 패널 열기",
|
"openPanel": "설정 패널 열기",
|
||||||
"draggingStatus": "드래그 중...",
|
"draggingStatus": "드래그 중...",
|
||||||
"toggleAndMove": "클릭하여 토글, 드래그하여 이동"
|
"toggleAndMove": "클릭하여 토글, 드래그하여 이동"
|
||||||
},
|
|
||||||
"whisper": {
|
|
||||||
"modes": {
|
|
||||||
"default": "기본 모드",
|
|
||||||
"defaultDescription": "음성을 그대로 텍스트로 변환",
|
|
||||||
"prompt": "프롬프트 향상",
|
|
||||||
"promptDescription": "거친 아이디어를 명확하고 상세한 AI 프롬프트로 변환",
|
|
||||||
"vibe": "Vibe 모드",
|
|
||||||
"vibeDescription": "아이디어를 상세한 에이전트 지침 형식으로 변환"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"terminalShortcuts": {
|
"terminalShortcuts": {
|
||||||
|
|||||||
@@ -55,8 +55,7 @@
|
|||||||
"appearance": "Внешний вид",
|
"appearance": "Внешний вид",
|
||||||
"toolDisplay": "Отображение инструментов",
|
"toolDisplay": "Отображение инструментов",
|
||||||
"viewOptions": "Параметры просмотра",
|
"viewOptions": "Параметры просмотра",
|
||||||
"inputSettings": "Настройки ввода",
|
"inputSettings": "Настройки ввода"
|
||||||
"whisperDictation": "Диктовка Whisper"
|
|
||||||
},
|
},
|
||||||
"darkMode": "Темная тема",
|
"darkMode": "Темная тема",
|
||||||
"autoExpandTools": "Автоматически разворачивать инструменты",
|
"autoExpandTools": "Автоматически разворачивать инструменты",
|
||||||
@@ -71,16 +70,6 @@
|
|||||||
"openPanel": "Открыть панель настроек",
|
"openPanel": "Открыть панель настроек",
|
||||||
"draggingStatus": "Перетаскивание...",
|
"draggingStatus": "Перетаскивание...",
|
||||||
"toggleAndMove": "Нажмите для переключения, перетащите для перемещения"
|
"toggleAndMove": "Нажмите для переключения, перетащите для перемещения"
|
||||||
},
|
|
||||||
"whisper": {
|
|
||||||
"modes": {
|
|
||||||
"default": "Режим по умолчанию",
|
|
||||||
"defaultDescription": "Прямая транскрипция вашей речи",
|
|
||||||
"prompt": "Улучшение запроса",
|
|
||||||
"promptDescription": "Преобразование грубых идей в четкие, детальные AI-запросы",
|
|
||||||
"vibe": "Режим Vibe",
|
|
||||||
"vibeDescription": "Форматирование идей как четких инструкций агента с деталями"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"terminalShortcuts": {
|
"terminalShortcuts": {
|
||||||
|
|||||||
@@ -55,8 +55,7 @@
|
|||||||
"appearance": "外观",
|
"appearance": "外观",
|
||||||
"toolDisplay": "工具显示",
|
"toolDisplay": "工具显示",
|
||||||
"viewOptions": "视图选项",
|
"viewOptions": "视图选项",
|
||||||
"inputSettings": "输入设置",
|
"inputSettings": "输入设置"
|
||||||
"whisperDictation": "Whisper 听写"
|
|
||||||
},
|
},
|
||||||
"darkMode": "深色模式",
|
"darkMode": "深色模式",
|
||||||
"autoExpandTools": "自动展开工具",
|
"autoExpandTools": "自动展开工具",
|
||||||
@@ -71,16 +70,6 @@
|
|||||||
"openPanel": "打开设置面板",
|
"openPanel": "打开设置面板",
|
||||||
"draggingStatus": "正在拖拽...",
|
"draggingStatus": "正在拖拽...",
|
||||||
"toggleAndMove": "点击切换,拖拽移动"
|
"toggleAndMove": "点击切换,拖拽移动"
|
||||||
},
|
|
||||||
"whisper": {
|
|
||||||
"modes": {
|
|
||||||
"default": "默认模式",
|
|
||||||
"defaultDescription": "直接转录您的语音",
|
|
||||||
"prompt": "提示词增强",
|
|
||||||
"promptDescription": "将粗略的想法转化为清晰、详细的 AI 提示词",
|
|
||||||
"vibe": "Vibe 模式",
|
|
||||||
"vibeDescription": "将想法格式化为带有详细说明的清晰智能体指令"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"terminalShortcuts": {
|
"terminalShortcuts": {
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ 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;
|
||||||
@@ -79,6 +81,7 @@ export interface SessionSlot {
|
|||||||
total: number;
|
total: number;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
tokenUsage: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: NormalizedMessage[] = [];
|
const EMPTY: NormalizedMessage[] = [];
|
||||||
@@ -95,6 +98,7 @@ function createEmptySlot(): SessionSlot {
|
|||||||
total: 0,
|
total: 0,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
tokenUsage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +208,9 @@ 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;
|
||||||
|
|||||||
@@ -147,13 +147,6 @@ export const api = {
|
|||||||
headers: {}, // Let browser set Content-Type for FormData
|
headers: {}, // Let browser set Content-Type for FormData
|
||||||
}),
|
}),
|
||||||
|
|
||||||
transcribe: (formData) =>
|
|
||||||
authenticatedFetch('/api/transcribe', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: {}, // Let browser set Content-Type for FormData
|
|
||||||
}),
|
|
||||||
|
|
||||||
// TaskMaster endpoints
|
// TaskMaster endpoints
|
||||||
taskmaster: {
|
taskmaster: {
|
||||||
// Initialize TaskMaster in a project
|
// Initialize TaskMaster in a project
|
||||||
|
|||||||
Reference in New Issue
Block a user