mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-23 14:57:25 +00:00
Compare commits
1 Commits
main
...
refactor/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b3bc54d1b |
@@ -24,6 +24,8 @@ import {
|
|||||||
notifyRunStopped,
|
notifyRunStopped,
|
||||||
notifyUserIfEnabled
|
notifyUserIfEnabled
|
||||||
} from './services/notification-orchestrator.js';
|
} from './services/notification-orchestrator.js';
|
||||||
|
import { claudeAdapter } from './providers/claude/adapter.js';
|
||||||
|
import { createNormalizedMessage } from './providers/types.js';
|
||||||
|
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
const pendingToolApprovals = new Map();
|
const pendingToolApprovals = new Map();
|
||||||
@@ -142,7 +144,7 @@ function matchesToolPermission(entry, toolName, input) {
|
|||||||
* @returns {Object} SDK-compatible options
|
* @returns {Object} SDK-compatible options
|
||||||
*/
|
*/
|
||||||
function mapCliOptionsToSDK(options = {}) {
|
function mapCliOptionsToSDK(options = {}) {
|
||||||
const { sessionId, cwd, toolsSettings, permissionMode, images } = options;
|
const { sessionId, cwd, toolsSettings, permissionMode } = options;
|
||||||
|
|
||||||
const sdkOptions = {};
|
const sdkOptions = {};
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
// Map model (default to sonnet)
|
// Map model (default to sonnet)
|
||||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||||
console.log(`Using model: ${sdkOptions.model}`);
|
// Model logged at query start below
|
||||||
|
|
||||||
// Map system prompt configuration
|
// Map system prompt configuration
|
||||||
sdkOptions.systemPrompt = {
|
sdkOptions.systemPrompt = {
|
||||||
@@ -304,7 +306,7 @@ function extractTokenBudget(resultMessage) {
|
|||||||
// This is the user's budget limit, not the model's context window
|
// This is the user's budget limit, not the model's context window
|
||||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
||||||
|
|
||||||
console.log(`Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
|
// Token calc logged via token-budget WS event
|
||||||
|
|
||||||
return {
|
return {
|
||||||
used: totalUsed,
|
used: totalUsed,
|
||||||
@@ -360,7 +362,7 @@ async function handleImages(command, images, cwd) {
|
|||||||
modifiedCommand = command + imageNote;
|
modifiedCommand = command + imageNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
|
// Images processed
|
||||||
return { modifiedCommand, tempImagePaths, tempDir };
|
return { modifiedCommand, tempImagePaths, tempDir };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing images for SDK:', error);
|
console.error('Error processing images for SDK:', error);
|
||||||
@@ -393,7 +395,7 @@ async function cleanupTempFiles(tempImagePaths, tempDir) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Cleaned up ${tempImagePaths.length} temp image files`);
|
// Temp files cleaned
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during temp file cleanup:', error);
|
console.error('Error during temp file cleanup:', error);
|
||||||
}
|
}
|
||||||
@@ -413,7 +415,7 @@ async function loadMcpConfig(cwd) {
|
|||||||
await fs.access(claudeConfigPath);
|
await fs.access(claudeConfigPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File doesn't exist, return null
|
// File doesn't exist, return null
|
||||||
console.log('No ~/.claude.json found, proceeding without MCP servers');
|
// No config file
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +435,7 @@ async function loadMcpConfig(cwd) {
|
|||||||
// Add global MCP servers
|
// Add global MCP servers
|
||||||
if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
|
if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
|
||||||
mcpServers = { ...claudeConfig.mcpServers };
|
mcpServers = { ...claudeConfig.mcpServers };
|
||||||
console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`);
|
// Global MCP servers loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add/override with project-specific MCP servers
|
// Add/override with project-specific MCP servers
|
||||||
@@ -441,17 +443,14 @@ async function loadMcpConfig(cwd) {
|
|||||||
const projectConfig = claudeConfig.claudeProjects[cwd];
|
const projectConfig = claudeConfig.claudeProjects[cwd];
|
||||||
if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
|
if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
|
||||||
mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
|
mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
|
||||||
console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`);
|
// Project MCP servers merged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return null if no servers found
|
// Return null if no servers found
|
||||||
if (Object.keys(mcpServers).length === 0) {
|
if (Object.keys(mcpServers).length === 0) {
|
||||||
console.log('No MCP servers configured');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
|
|
||||||
return mcpServers;
|
return mcpServers;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading MCP config:', error.message);
|
console.error('Error loading MCP config:', error.message);
|
||||||
@@ -541,13 +540,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestId = createRequestId();
|
const requestId = createRequestId();
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||||
type: 'claude-permission-request',
|
|
||||||
requestId,
|
|
||||||
toolName,
|
|
||||||
input,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
emitNotification(createNotificationEvent({
|
emitNotification(createNotificationEvent({
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
@@ -569,12 +562,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
_receivedAt: new Date(),
|
_receivedAt: new Date(),
|
||||||
},
|
},
|
||||||
onCancel: (reason) => {
|
onCancel: (reason) => {
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||||
type: 'claude-permission-cancelled',
|
|
||||||
requestId,
|
|
||||||
reason,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!decision) {
|
if (!decision) {
|
||||||
@@ -650,39 +638,35 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
// Send session-created event only once for new sessions
|
// Send session-created event only once for new sessions
|
||||||
if (!sessionId && !sessionCreatedSent) {
|
if (!sessionId && !sessionCreatedSent) {
|
||||||
sessionCreatedSent = true;
|
sessionCreatedSent = true;
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
|
||||||
type: 'session-created',
|
|
||||||
sessionId: capturedSessionId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
|
// session_id already captured
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform and send message to WebSocket
|
// Transform and normalize message via adapter
|
||||||
const transformedMessage = transformMessage(message);
|
const transformedMessage = transformMessage(message);
|
||||||
ws.send({
|
const sid = capturedSessionId || sessionId || null;
|
||||||
type: 'claude-response',
|
|
||||||
data: transformedMessage,
|
// Use adapter to normalize SDK events into NormalizedMessage[]
|
||||||
sessionId: capturedSessionId || sessionId || null
|
const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid);
|
||||||
});
|
for (const msg of normalized) {
|
||||||
|
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
|
||||||
|
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
|
||||||
|
msg.parentToolUseId = transformedMessage.parentToolUseId;
|
||||||
|
}
|
||||||
|
ws.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
// Extract and send token budget updates from result messages
|
// Extract and send token budget updates from result messages
|
||||||
if (message.type === 'result') {
|
if (message.type === 'result') {
|
||||||
const models = Object.keys(message.modelUsage || {});
|
const models = Object.keys(message.modelUsage || {});
|
||||||
if (models.length > 0) {
|
if (models.length > 0) {
|
||||||
console.log("---> Model was sent using:", models);
|
// Model info available in result message
|
||||||
}
|
}
|
||||||
const tokenBudget = extractTokenBudget(message);
|
const tokenBudgetData = extractTokenBudget(message);
|
||||||
if (tokenBudget) {
|
if (tokenBudgetData) {
|
||||||
console.log('Token budget from modelUsage:', tokenBudget);
|
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||||
ws.send({
|
|
||||||
type: 'token-budget',
|
|
||||||
data: tokenBudget,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,13 +680,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||||
|
|
||||||
// Send completion event
|
// Send completion event
|
||||||
console.log('Streaming complete, sending claude-complete event');
|
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
|
||||||
ws.send({
|
|
||||||
type: 'claude-complete',
|
|
||||||
sessionId: capturedSessionId,
|
|
||||||
exitCode: 0,
|
|
||||||
isNewSession: !sessionId && !!command
|
|
||||||
});
|
|
||||||
notifyRunStopped({
|
notifyRunStopped({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
@@ -710,7 +688,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
sessionName: sessionSummary,
|
sessionName: sessionSummary,
|
||||||
stopReason: 'completed'
|
stopReason: 'completed'
|
||||||
});
|
});
|
||||||
console.log('claude-complete event sent');
|
// Complete
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('SDK query error:', error);
|
console.error('SDK query error:', error);
|
||||||
@@ -724,11 +702,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||||
|
|
||||||
// Send error to WebSocket
|
// Send error to WebSocket
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||||
type: 'claude-error',
|
|
||||||
error: error.message,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
notifyRunFailed({
|
notifyRunFailed({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
|
import { cursorAdapter } from './providers/cursor/adapter.js';
|
||||||
|
import { createNormalizedMessage } from './providers/types.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for better command execution
|
// Use cross-spawn on Windows for better command execution
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
@@ -172,75 +174,42 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Send session-created event only once for new sessions
|
// Send session-created event only once for new sessions
|
||||||
if (!sessionId && !sessionCreatedSent) {
|
if (!sessionId && !sessionCreatedSent) {
|
||||||
sessionCreatedSent = true;
|
sessionCreatedSent = true;
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, model: response.model, cwd: response.cwd, sessionId: capturedSessionId, provider: 'cursor' }));
|
||||||
type: 'session-created',
|
|
||||||
sessionId: capturedSessionId,
|
|
||||||
model: response.model,
|
|
||||||
cwd: response.cwd
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send system info to frontend
|
// System info — no longer needed by the frontend (session-lifecycle 'created' handles nav).
|
||||||
ws.send({
|
|
||||||
type: 'cursor-system',
|
|
||||||
data: response,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'user':
|
case 'user':
|
||||||
// Forward user message
|
// User messages are not displayed in the UI — skip.
|
||||||
ws.send({
|
|
||||||
type: 'cursor-user',
|
|
||||||
data: response,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'assistant':
|
case 'assistant':
|
||||||
// Accumulate assistant message chunks
|
// Accumulate assistant message chunks
|
||||||
if (response.message && response.message.content && response.message.content.length > 0) {
|
if (response.message && response.message.content && response.message.content.length > 0) {
|
||||||
const textContent = response.message.content[0].text;
|
const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null);
|
||||||
|
for (const msg of normalized) ws.send(msg);
|
||||||
// Send as Claude-compatible format for frontend
|
|
||||||
ws.send({
|
|
||||||
type: 'claude-response',
|
|
||||||
data: {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
delta: {
|
|
||||||
type: 'text_delta',
|
|
||||||
text: textContent
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'result':
|
case 'result': {
|
||||||
// Session complete
|
// Session complete — send stream end + lifecycle complete with result payload
|
||||||
console.log('Cursor session result:', response);
|
console.log('Cursor session result:', response);
|
||||||
|
const resultText = typeof response.result === 'string' ? response.result : '';
|
||||||
// Do not emit an extra content_block_stop here.
|
ws.send(createNormalizedMessage({
|
||||||
// The UI already finalizes the streaming message in cursor-result handling,
|
kind: 'complete',
|
||||||
// and emitting both can produce duplicate assistant messages.
|
exitCode: response.subtype === 'success' ? 0 : 1,
|
||||||
ws.send({
|
resultText,
|
||||||
type: 'cursor-result',
|
isError: response.subtype !== 'success',
|
||||||
sessionId: capturedSessionId || sessionId,
|
sessionId: capturedSessionId || sessionId, provider: 'cursor',
|
||||||
data: response,
|
}));
|
||||||
success: response.subtype === 'success'
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Forward any other message types
|
// Unknown message types — ignore.
|
||||||
ws.send({
|
|
||||||
type: 'cursor-response',
|
|
||||||
data: response,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.log('Non-JSON response:', line);
|
console.log('Non-JSON response:', line);
|
||||||
@@ -249,12 +218,9 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not JSON, send as raw text
|
// If not JSON, send as stream delta via adapter
|
||||||
ws.send({
|
const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null);
|
||||||
type: 'cursor-output',
|
for (const msg of normalized) ws.send(msg);
|
||||||
data: line,
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,12 +248,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'error', content: stderrText, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
||||||
type: 'cursor-error',
|
|
||||||
error: stderrText,
|
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
|
||||||
provider: 'cursor'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle process completion
|
// Handle process completion
|
||||||
@@ -314,13 +275,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
|
||||||
type: 'claude-complete',
|
|
||||||
sessionId: finalSessionId,
|
|
||||||
exitCode: code,
|
|
||||||
provider: 'cursor',
|
|
||||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
|
||||||
});
|
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
@@ -339,12 +294,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||||
activeCursorProcesses.delete(finalSessionId);
|
activeCursorProcesses.delete(finalSessionId);
|
||||||
|
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
||||||
type: 'cursor-error',
|
|
||||||
error: error.message,
|
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
|
||||||
provider: 'cursor'
|
|
||||||
});
|
|
||||||
notifyTerminalState({ error });
|
notifyTerminalState({ error });
|
||||||
|
|
||||||
settleOnce(() => reject(error));
|
settleOnce(() => reject(error));
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { getSessions, getSessionMessages } from './projects.js';
|
|
||||||
import sessionManager from './sessionManager.js';
|
import sessionManager from './sessionManager.js';
|
||||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
|
import { createNormalizedMessage } from './providers/types.js';
|
||||||
|
|
||||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
async function spawnGemini(command, options = {}, ws) {
|
async function spawnGemini(command, options = {}, ws) {
|
||||||
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images, sessionSummary } = options;
|
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
|
||||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||||
@@ -219,7 +219,6 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
geminiProcess.stdin.end();
|
geminiProcess.stdin.end();
|
||||||
|
|
||||||
// Add timeout handler
|
// Add timeout handler
|
||||||
let hasReceivedOutput = false;
|
|
||||||
const timeoutMs = 120000; // 120 seconds for slower models
|
const timeoutMs = 120000; // 120 seconds for slower models
|
||||||
let timeout;
|
let timeout;
|
||||||
|
|
||||||
@@ -228,12 +227,7 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
|
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
|
||||||
terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`;
|
terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`;
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
type: 'gemini-error',
|
|
||||||
sessionId: socketSessionId,
|
|
||||||
error: terminalFailureReason,
|
|
||||||
provider: 'gemini'
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
geminiProcess.kill('SIGTERM');
|
geminiProcess.kill('SIGTERM');
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
@@ -295,7 +289,6 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
// Handle stdout
|
// Handle stdout
|
||||||
geminiProcess.stdout.on('data', (data) => {
|
geminiProcess.stdout.on('data', (data) => {
|
||||||
const rawOutput = data.toString();
|
const rawOutput = data.toString();
|
||||||
hasReceivedOutput = true;
|
|
||||||
startTimeout(); // Re-arm the timeout
|
startTimeout(); // Re-arm the timeout
|
||||||
|
|
||||||
// For new sessions, create a session ID FIRST
|
// For new sessions, create a session ID FIRST
|
||||||
@@ -319,21 +312,7 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
|
|
||||||
ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
|
ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
|
||||||
|
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
|
||||||
type: 'session-created',
|
|
||||||
sessionId: capturedSessionId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit fake system init so the frontend immediately navigates and saves the session
|
|
||||||
ws.send({
|
|
||||||
type: 'claude-response',
|
|
||||||
sessionId: capturedSessionId,
|
|
||||||
data: {
|
|
||||||
type: 'system',
|
|
||||||
subtype: 'init',
|
|
||||||
session_id: capturedSessionId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseHandler) {
|
if (responseHandler) {
|
||||||
@@ -346,14 +325,7 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
assistantBlocks.push({ type: 'text', text: rawOutput });
|
assistantBlocks.push({ type: 'text', text: rawOutput });
|
||||||
}
|
}
|
||||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
|
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'stream_delta', content: rawOutput, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
type: 'gemini-response',
|
|
||||||
sessionId: socketSessionId,
|
|
||||||
data: {
|
|
||||||
type: 'message',
|
|
||||||
content: rawOutput
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -370,12 +342,7 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
|
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'error', content: errorMsg, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
type: 'gemini-error',
|
|
||||||
sessionId: socketSessionId,
|
|
||||||
error: errorMsg,
|
|
||||||
provider: 'gemini'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle process completion
|
// Handle process completion
|
||||||
@@ -397,13 +364,7 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
|
||||||
type: 'claude-complete', // Use claude-complete for compatibility with UI
|
|
||||||
sessionId: finalSessionId,
|
|
||||||
exitCode: code,
|
|
||||||
provider: 'gemini',
|
|
||||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up temporary image files if any
|
// Clean up temporary image files if any
|
||||||
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
||||||
@@ -434,12 +395,7 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
activeGeminiProcesses.delete(finalSessionId);
|
activeGeminiProcesses.delete(finalSessionId);
|
||||||
|
|
||||||
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||||
ws.send({
|
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' }));
|
||||||
type: 'gemini-error',
|
|
||||||
sessionId: errorSessionId,
|
|
||||||
error: error.message,
|
|
||||||
provider: 'gemini'
|
|
||||||
});
|
|
||||||
notifyTerminalState({ error });
|
notifyTerminalState({ error });
|
||||||
|
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Gemini Response Handler - JSON Stream processing
|
// Gemini Response Handler - JSON Stream processing
|
||||||
|
import { geminiAdapter } from './providers/gemini/adapter.js';
|
||||||
|
|
||||||
class GeminiResponseHandler {
|
class GeminiResponseHandler {
|
||||||
constructor(ws, options = {}) {
|
constructor(ws, options = {}) {
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
@@ -27,13 +29,12 @@ class GeminiResponseHandler {
|
|||||||
this.handleEvent(event);
|
this.handleEvent(event);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Not a JSON line, probably debug output or CLI warnings
|
// Not a JSON line, probably debug output or CLI warnings
|
||||||
// console.error('[Gemini Handler] Non-JSON line ignored:', line);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEvent(event) {
|
handleEvent(event) {
|
||||||
const socketSessionId = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
|
const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
|
||||||
|
|
||||||
if (event.type === 'init') {
|
if (event.type === 'init') {
|
||||||
if (this.onInit) {
|
if (this.onInit) {
|
||||||
@@ -42,88 +43,26 @@ class GeminiResponseHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invoke per-type callbacks for session tracking
|
||||||
if (event.type === 'message' && event.role === 'assistant') {
|
if (event.type === 'message' && event.role === 'assistant') {
|
||||||
const content = event.content || '';
|
const content = event.content || '';
|
||||||
|
|
||||||
// Notify the parent CLI handler of accumulated text
|
|
||||||
if (this.onContentFragment && content) {
|
if (this.onContentFragment && content) {
|
||||||
this.onContentFragment(content);
|
this.onContentFragment(content);
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'tool_use' && this.onToolUse) {
|
||||||
|
this.onToolUse(event);
|
||||||
|
} else if (event.type === 'tool_result' && this.onToolResult) {
|
||||||
|
this.onToolResult(event);
|
||||||
|
}
|
||||||
|
|
||||||
let payload = {
|
// Normalize via adapter and send all resulting messages
|
||||||
type: 'gemini-response',
|
const normalized = geminiAdapter.normalizeMessage(event, sid);
|
||||||
data: {
|
for (const msg of normalized) {
|
||||||
type: 'message',
|
this.ws.send(msg);
|
||||||
content: content,
|
|
||||||
isPartial: event.delta === true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
|
||||||
this.ws.send(payload);
|
|
||||||
}
|
|
||||||
else if (event.type === 'tool_use') {
|
|
||||||
if (this.onToolUse) {
|
|
||||||
this.onToolUse(event);
|
|
||||||
}
|
|
||||||
let payload = {
|
|
||||||
type: 'gemini-tool-use',
|
|
||||||
toolName: event.tool_name,
|
|
||||||
toolId: event.tool_id,
|
|
||||||
parameters: event.parameters || {}
|
|
||||||
};
|
|
||||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
|
||||||
this.ws.send(payload);
|
|
||||||
}
|
|
||||||
else if (event.type === 'tool_result') {
|
|
||||||
if (this.onToolResult) {
|
|
||||||
this.onToolResult(event);
|
|
||||||
}
|
|
||||||
let payload = {
|
|
||||||
type: 'gemini-tool-result',
|
|
||||||
toolId: event.tool_id,
|
|
||||||
status: event.status,
|
|
||||||
output: event.output || ''
|
|
||||||
};
|
|
||||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
|
||||||
this.ws.send(payload);
|
|
||||||
}
|
|
||||||
else if (event.type === 'result') {
|
|
||||||
// Send a finalize message string
|
|
||||||
let payload = {
|
|
||||||
type: 'gemini-response',
|
|
||||||
data: {
|
|
||||||
type: 'message',
|
|
||||||
content: '',
|
|
||||||
isPartial: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
|
||||||
this.ws.send(payload);
|
|
||||||
|
|
||||||
if (event.stats && event.stats.total_tokens) {
|
|
||||||
let statsPayload = {
|
|
||||||
type: 'claude-status',
|
|
||||||
data: {
|
|
||||||
status: 'Complete',
|
|
||||||
tokens: event.stats.total_tokens
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (socketSessionId) statsPayload.sessionId = socketSessionId;
|
|
||||||
this.ws.send(statsPayload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (event.type === 'error') {
|
|
||||||
let payload = {
|
|
||||||
type: 'gemini-error',
|
|
||||||
error: event.error || event.message || 'Unknown Gemini streaming error'
|
|
||||||
};
|
|
||||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
|
||||||
this.ws.send(payload);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
forceFlush() {
|
forceFlush() {
|
||||||
// If the buffer has content, try to parse it one last time
|
|
||||||
if (this.buffer.trim()) {
|
if (this.buffer.trim()) {
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(this.buffer);
|
const event = JSON.parse(this.buffer);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import pty from 'node-pty';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import mime from 'mime-types';
|
import mime from 'mime-types';
|
||||||
|
|
||||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
|
import { getProjects, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
|
||||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
||||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||||
@@ -65,6 +65,8 @@ import userRoutes from './routes/user.js';
|
|||||||
import codexRoutes from './routes/codex.js';
|
import codexRoutes from './routes/codex.js';
|
||||||
import geminiRoutes from './routes/gemini.js';
|
import geminiRoutes from './routes/gemini.js';
|
||||||
import pluginsRoutes from './routes/plugins.js';
|
import pluginsRoutes from './routes/plugins.js';
|
||||||
|
import messagesRoutes from './routes/messages.js';
|
||||||
|
import { createNormalizedMessage } from './providers/types.js';
|
||||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||||
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
||||||
import { configureWebPush } from './services/vapid-keys.js';
|
import { configureWebPush } from './services/vapid-keys.js';
|
||||||
@@ -396,6 +398,9 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
|
|||||||
// Plugins API Routes (protected)
|
// Plugins API Routes (protected)
|
||||||
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||||
|
|
||||||
|
// Unified session messages route (protected)
|
||||||
|
app.use('/api/sessions', authenticateToken, messagesRoutes);
|
||||||
|
|
||||||
// Agent API Routes (uses API key authentication)
|
// Agent API Routes (uses API key authentication)
|
||||||
app.use('/api/agent', agentRoutes);
|
app.use('/api/agent', agentRoutes);
|
||||||
|
|
||||||
@@ -509,31 +514,6 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get messages for a specific session
|
|
||||||
app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { projectName, sessionId } = req.params;
|
|
||||||
const { limit, offset } = req.query;
|
|
||||||
|
|
||||||
// Parse limit and offset if provided
|
|
||||||
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
|
||||||
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
|
||||||
|
|
||||||
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
|
||||||
|
|
||||||
// Handle both old and new response formats
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
// Backward compatibility: no pagination parameters were provided
|
|
||||||
res.json({ messages: result });
|
|
||||||
} else {
|
|
||||||
// New format with pagination info
|
|
||||||
res.json(result);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rename project endpoint
|
// Rename project endpoint
|
||||||
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
|
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -958,7 +938,6 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = await getFileTree(actualPath, 10, 0, true);
|
const files = await getFileTree(actualPath, 10, 0, true);
|
||||||
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
|
||||||
res.json(files);
|
res.json(files);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ERROR] File tree error:', error.message);
|
console.error('[ERROR] File tree error:', error.message);
|
||||||
@@ -1463,6 +1442,10 @@ wss.on('connection', (ws, request) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||||
|
*
|
||||||
|
* Provider files use `createNormalizedMessage()` from `providers/types.js` and
|
||||||
|
* adapter `normalizeMessage()` to produce unified NormalizedMessage events.
|
||||||
|
* The writer simply serialises and sends.
|
||||||
*/
|
*/
|
||||||
class WebSocketWriter {
|
class WebSocketWriter {
|
||||||
constructor(ws, userId = null) {
|
constructor(ws, userId = null) {
|
||||||
@@ -1474,7 +1457,6 @@ class WebSocketWriter {
|
|||||||
|
|
||||||
send(data) {
|
send(data) {
|
||||||
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
||||||
// Providers send raw objects, we stringify for WebSocket
|
|
||||||
this.ws.send(JSON.stringify(data));
|
this.ws.send(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1555,12 +1537,7 @@ function handleChatConnection(ws, request) {
|
|||||||
success = await abortClaudeSDKSession(data.sessionId);
|
success = await abortClaudeSDKSession(data.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.send({
|
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
|
||||||
type: 'session-aborted',
|
|
||||||
sessionId: data.sessionId,
|
|
||||||
provider,
|
|
||||||
success
|
|
||||||
});
|
|
||||||
} else if (data.type === 'claude-permission-response') {
|
} else if (data.type === 'claude-permission-response') {
|
||||||
// Relay UI approval decisions back into the SDK control flow.
|
// Relay UI approval decisions back into the SDK control flow.
|
||||||
// This does not persist permissions; it only resolves the in-flight request,
|
// This does not persist permissions; it only resolves the in-flight request,
|
||||||
@@ -1576,12 +1553,7 @@ function handleChatConnection(ws, request) {
|
|||||||
} else if (data.type === 'cursor-abort') {
|
} else if (data.type === 'cursor-abort') {
|
||||||
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
||||||
const success = abortCursorSession(data.sessionId);
|
const success = abortCursorSession(data.sessionId);
|
||||||
writer.send({
|
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
|
||||||
type: 'session-aborted',
|
|
||||||
sessionId: data.sessionId,
|
|
||||||
provider: 'cursor',
|
|
||||||
success
|
|
||||||
});
|
|
||||||
} else if (data.type === 'check-session-status') {
|
} else if (data.type === 'check-session-status') {
|
||||||
// Check if a specific session is currently processing
|
// Check if a specific session is currently processing
|
||||||
const provider = data.provider || 'claude';
|
const provider = data.provider || 'claude';
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
import { Codex } from '@openai/codex-sdk';
|
import { Codex } from '@openai/codex-sdk';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
|
import { codexAdapter } from './providers/codex/adapter.js';
|
||||||
|
import { createNormalizedMessage } from './providers/types.js';
|
||||||
|
|
||||||
// Track active sessions
|
// Track active sessions
|
||||||
const activeCodexSessions = new Map();
|
const activeCodexSessions = new Map();
|
||||||
@@ -241,11 +243,7 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send session created event
|
// Send session created event
|
||||||
sendMessage(ws, {
|
sendMessage(ws, createNormalizedMessage({ kind: 'session_created', newSessionId: currentSessionId, sessionId: currentSessionId, provider: 'codex' }));
|
||||||
type: 'session-created',
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
provider: 'codex'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute with streaming
|
// Execute with streaming
|
||||||
const streamedTurn = await thread.runStreamed(command, {
|
const streamedTurn = await thread.runStreamed(command, {
|
||||||
@@ -265,11 +263,11 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
|
|
||||||
const transformed = transformCodexEvent(event);
|
const transformed = transformCodexEvent(event);
|
||||||
|
|
||||||
sendMessage(ws, {
|
// Normalize the transformed event into NormalizedMessage(s) via adapter
|
||||||
type: 'codex-response',
|
const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId);
|
||||||
data: transformed,
|
for (const msg of normalizedMsgs) {
|
||||||
sessionId: currentSessionId
|
sendMessage(ws, msg);
|
||||||
});
|
}
|
||||||
|
|
||||||
if (event.type === 'turn.failed' && !terminalFailure) {
|
if (event.type === 'turn.failed' && !terminalFailure) {
|
||||||
terminalFailure = event.error || new Error('Turn failed');
|
terminalFailure = event.error || new Error('Turn failed');
|
||||||
@@ -285,25 +283,13 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
// Extract and send token usage if available (normalized to match Claude format)
|
// Extract and send token usage if available (normalized to match Claude format)
|
||||||
if (event.type === 'turn.completed' && event.usage) {
|
if (event.type === 'turn.completed' && event.usage) {
|
||||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
||||||
sendMessage(ws, {
|
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' }));
|
||||||
type: 'token-budget',
|
|
||||||
data: {
|
|
||||||
used: totalTokens,
|
|
||||||
total: 200000 // Default context window for Codex models
|
|
||||||
},
|
|
||||||
sessionId: currentSessionId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send completion event
|
// Send completion event
|
||||||
if (!terminalFailure) {
|
if (!terminalFailure) {
|
||||||
sendMessage(ws, {
|
sendMessage(ws, createNormalizedMessage({ kind: 'complete', actualSessionId: thread.id, sessionId: currentSessionId, provider: 'codex' }));
|
||||||
type: 'codex-complete',
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
actualSessionId: thread.id,
|
|
||||||
provider: 'codex'
|
|
||||||
});
|
|
||||||
notifyRunStopped({
|
notifyRunStopped({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
@@ -322,12 +308,7 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
|
|
||||||
if (!wasAborted) {
|
if (!wasAborted) {
|
||||||
console.error('[Codex] Error:', error);
|
console.error('[Codex] Error:', error);
|
||||||
sendMessage(ws, {
|
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' }));
|
||||||
type: 'codex-error',
|
|
||||||
error: error.message,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
provider: 'codex'
|
|
||||||
});
|
|
||||||
if (!terminalFailure) {
|
if (!terminalFailure) {
|
||||||
notifyRunFailed({
|
notifyRunFailed({
|
||||||
userId: ws?.userId || null,
|
userId: ws?.userId || null,
|
||||||
|
|||||||
@@ -1014,7 +1014,7 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
|
|||||||
messages.push(entry);
|
messages.push(entry);
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.warn('Error parsing line:', parseError.message);
|
// Silently skip malformed JSONL lines (common with concurrent writes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
278
server/providers/claude/adapter.js
Normal file
278
server/providers/claude/adapter.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Claude provider adapter.
|
||||||
|
*
|
||||||
|
* Normalizes Claude SDK session history into NormalizedMessage format.
|
||||||
|
* @module adapters/claude
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSessionMessages } from '../../projects.js';
|
||||||
|
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||||
|
import { isInternalContent } from '../utils.js';
|
||||||
|
|
||||||
|
const PROVIDER = 'claude';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s).
|
||||||
|
* Handles both history entries (JSONL `{ message: { role, content } }`) and
|
||||||
|
* realtime streaming events (`content_block_delta`, `content_block_stop`, etc.).
|
||||||
|
* @param {object} raw - A single entry from JSONL or a live SDK event
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns {import('../types.js').NormalizedMessage[]}
|
||||||
|
*/
|
||||||
|
export function normalizeMessage(raw, sessionId) {
|
||||||
|
// ── Streaming events (realtime) ──────────────────────────────────────────
|
||||||
|
if (raw.type === 'content_block_delta' && raw.delta?.text) {
|
||||||
|
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
|
||||||
|
}
|
||||||
|
if (raw.type === 'content_block_stop') {
|
||||||
|
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History / full-message events ────────────────────────────────────────
|
||||||
|
const messages = [];
|
||||||
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
|
const baseId = raw.uuid || generateMessageId('claude');
|
||||||
|
|
||||||
|
// User message
|
||||||
|
if (raw.message?.role === 'user' && raw.message?.content) {
|
||||||
|
if (Array.isArray(raw.message.content)) {
|
||||||
|
// Handle tool_result parts
|
||||||
|
for (const part of raw.message.content) {
|
||||||
|
if (part.type === 'tool_result') {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_tr_${part.tool_use_id}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolId: part.tool_use_id,
|
||||||
|
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
|
||||||
|
isError: Boolean(part.is_error),
|
||||||
|
subagentTools: raw.subagentTools,
|
||||||
|
toolUseResult: raw.toolUseResult,
|
||||||
|
}));
|
||||||
|
} else if (part.type === 'text') {
|
||||||
|
// Regular text parts from user
|
||||||
|
const text = part.text || '';
|
||||||
|
if (text && !isInternalContent(text)) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_text`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no text parts were found, check if it's a pure user message
|
||||||
|
if (messages.length === 0) {
|
||||||
|
const textParts = raw.message.content
|
||||||
|
.filter(p => p.type === 'text')
|
||||||
|
.map(p => p.text)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
if (textParts && !isInternalContent(textParts)) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_text`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'user',
|
||||||
|
content: textParts,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof raw.message.content === 'string') {
|
||||||
|
const text = raw.message.content;
|
||||||
|
if (text && !isInternalContent(text)) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking message
|
||||||
|
if (raw.type === 'thinking' && raw.message?.content) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content: raw.message.content,
|
||||||
|
}));
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool use result (codex-style in Claude)
|
||||||
|
if (raw.type === 'tool_use' && raw.toolName) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: raw.toolName,
|
||||||
|
toolInput: raw.toolInput,
|
||||||
|
toolId: raw.toolCallId || baseId,
|
||||||
|
}));
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === 'tool_result') {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolId: raw.toolCallId || '',
|
||||||
|
content: raw.output || '',
|
||||||
|
isError: false,
|
||||||
|
}));
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant message
|
||||||
|
if (raw.message?.role === 'assistant' && raw.message?.content) {
|
||||||
|
if (Array.isArray(raw.message.content)) {
|
||||||
|
let partIndex = 0;
|
||||||
|
for (const part of raw.message.content) {
|
||||||
|
if (part.type === 'text' && part.text) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIndex}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'assistant',
|
||||||
|
content: part.text,
|
||||||
|
}));
|
||||||
|
} else if (part.type === 'tool_use') {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIndex}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: part.name,
|
||||||
|
toolInput: part.input,
|
||||||
|
toolId: part.id,
|
||||||
|
}));
|
||||||
|
} else if (part.type === 'thinking' && part.thinking) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIndex}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content: part.thinking,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
partIndex++;
|
||||||
|
}
|
||||||
|
} else if (typeof raw.message.content === 'string') {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'assistant',
|
||||||
|
content: raw.message.content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('../types.js').ProviderAdapter}
|
||||||
|
*/
|
||||||
|
export const claudeAdapter = {
|
||||||
|
normalizeMessage,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch session history from JSONL files, returning normalized messages.
|
||||||
|
*/
|
||||||
|
async fetchHistory(sessionId, opts = {}) {
|
||||||
|
const { projectName, limit = null, offset = 0 } = opts;
|
||||||
|
if (!projectName) {
|
||||||
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await getSessionMessages(projectName, sessionId, limit, offset);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||||
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
|
||||||
|
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||||
|
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||||
|
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||||
|
|
||||||
|
// First pass: collect tool results for attachment to tool_use messages
|
||||||
|
const toolResultMap = new Map();
|
||||||
|
for (const raw of rawMessages) {
|
||||||
|
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
|
||||||
|
for (const part of raw.message.content) {
|
||||||
|
if (part.type === 'tool_result') {
|
||||||
|
toolResultMap.set(part.tool_use_id, {
|
||||||
|
content: part.content,
|
||||||
|
isError: Boolean(part.is_error),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
subagentTools: raw.subagentTools,
|
||||||
|
toolUseResult: raw.toolUseResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: normalize all messages
|
||||||
|
const normalized = [];
|
||||||
|
for (const raw of rawMessages) {
|
||||||
|
const entries = normalizeMessage(raw, sessionId);
|
||||||
|
normalized.push(...entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach tool results to their corresponding tool_use messages
|
||||||
|
for (const msg of normalized) {
|
||||||
|
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||||
|
const tr = toolResultMap.get(msg.toolId);
|
||||||
|
msg.toolResult = {
|
||||||
|
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||||
|
isError: tr.isError,
|
||||||
|
toolUseResult: tr.toolUseResult,
|
||||||
|
};
|
||||||
|
msg.subagentTools = tr.subagentTools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: normalized,
|
||||||
|
total,
|
||||||
|
hasMore,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
248
server/providers/codex/adapter.js
Normal file
248
server/providers/codex/adapter.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Codex (OpenAI) provider adapter.
|
||||||
|
*
|
||||||
|
* Normalizes Codex SDK session history into NormalizedMessage format.
|
||||||
|
* @module adapters/codex
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCodexSessionMessages } from '../../projects.js';
|
||||||
|
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||||
|
|
||||||
|
const PROVIDER = 'codex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw Codex JSONL message into NormalizedMessage(s).
|
||||||
|
* @param {object} raw - A single parsed message from Codex JSONL
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns {import('../types.js').NormalizedMessage[]}
|
||||||
|
*/
|
||||||
|
function normalizeCodexHistoryEntry(raw, sessionId) {
|
||||||
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
|
const baseId = raw.uuid || generateMessageId('codex');
|
||||||
|
|
||||||
|
// User message
|
||||||
|
if (raw.message?.role === 'user') {
|
||||||
|
const content = typeof raw.message.content === 'string'
|
||||||
|
? raw.message.content
|
||||||
|
: Array.isArray(raw.message.content)
|
||||||
|
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
|
||||||
|
: String(raw.message.content || '');
|
||||||
|
if (!content.trim()) return [];
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant message
|
||||||
|
if (raw.message?.role === 'assistant') {
|
||||||
|
const content = typeof raw.message.content === 'string'
|
||||||
|
? raw.message.content
|
||||||
|
: Array.isArray(raw.message.content)
|
||||||
|
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
|
||||||
|
: '';
|
||||||
|
if (!content.trim()) return [];
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking/reasoning
|
||||||
|
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content: raw.message?.content || '',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool use
|
||||||
|
if (raw.type === 'tool_use' || raw.toolName) {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: raw.toolName || 'Unknown',
|
||||||
|
toolInput: raw.toolInput,
|
||||||
|
toolId: raw.toolCallId || baseId,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool result
|
||||||
|
if (raw.type === 'tool_result') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolId: raw.toolCallId || '',
|
||||||
|
content: raw.output || '',
|
||||||
|
isError: Boolean(raw.isError),
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s).
|
||||||
|
* @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type)
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns {import('../types.js').NormalizedMessage[]}
|
||||||
|
*/
|
||||||
|
export function normalizeMessage(raw, sessionId) {
|
||||||
|
// History format: has message.role
|
||||||
|
if (raw.message?.role) {
|
||||||
|
return normalizeCodexHistoryEntry(raw, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
|
const baseId = raw.uuid || generateMessageId('codex');
|
||||||
|
|
||||||
|
// SDK event format (output of transformCodexEvent)
|
||||||
|
if (raw.type === 'item') {
|
||||||
|
switch (raw.itemType) {
|
||||||
|
case 'agent_message':
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'text', role: 'assistant', content: raw.message?.content || '',
|
||||||
|
})];
|
||||||
|
case 'reasoning':
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'thinking', content: raw.message?.content || '',
|
||||||
|
})];
|
||||||
|
case 'command_execution':
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'tool_use', toolName: 'Bash', toolInput: { command: raw.command },
|
||||||
|
toolId: baseId,
|
||||||
|
output: raw.output, exitCode: raw.exitCode, status: raw.status,
|
||||||
|
})];
|
||||||
|
case 'file_change':
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'tool_use', toolName: 'FileChanges', toolInput: raw.changes,
|
||||||
|
toolId: baseId, status: raw.status,
|
||||||
|
})];
|
||||||
|
case 'mcp_tool_call':
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'tool_use', toolName: raw.tool || 'MCP', toolInput: raw.arguments,
|
||||||
|
toolId: baseId, server: raw.server, result: raw.result,
|
||||||
|
error: raw.error, status: raw.status,
|
||||||
|
})];
|
||||||
|
case 'web_search':
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'tool_use', toolName: 'WebSearch', toolInput: { query: raw.query },
|
||||||
|
toolId: baseId,
|
||||||
|
})];
|
||||||
|
case 'todo_list':
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'tool_use', toolName: 'TodoList', toolInput: { items: raw.items },
|
||||||
|
toolId: baseId,
|
||||||
|
})];
|
||||||
|
case 'error':
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'error', content: raw.message?.content || 'Unknown error',
|
||||||
|
})];
|
||||||
|
default:
|
||||||
|
// Unknown item type — pass through as generic tool_use
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'tool_use', toolName: raw.itemType || 'Unknown',
|
||||||
|
toolInput: raw.item || raw, toolId: baseId,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === 'turn_complete') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'complete',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
if (raw.type === 'turn_failed') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'error', content: raw.error?.message || 'Turn failed',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('../types.js').ProviderAdapter}
|
||||||
|
*/
|
||||||
|
export const codexAdapter = {
|
||||||
|
normalizeMessage,
|
||||||
|
/**
|
||||||
|
* Fetch session history from Codex JSONL files.
|
||||||
|
*/
|
||||||
|
async fetchHistory(sessionId, opts = {}) {
|
||||||
|
const { limit = null, offset = 0 } = opts;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await getCodexSessionMessages(sessionId, limit, offset);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||||
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||||
|
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||||
|
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||||
|
const tokenUsage = result.tokenUsage || null;
|
||||||
|
|
||||||
|
const normalized = [];
|
||||||
|
for (const raw of rawMessages) {
|
||||||
|
const entries = normalizeCodexHistoryEntry(raw, sessionId);
|
||||||
|
normalized.push(...entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach tool results to tool_use messages
|
||||||
|
const toolResultMap = new Map();
|
||||||
|
for (const msg of normalized) {
|
||||||
|
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||||
|
toolResultMap.set(msg.toolId, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const msg of normalized) {
|
||||||
|
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||||
|
const tr = toolResultMap.get(msg.toolId);
|
||||||
|
msg.toolResult = { content: tr.content, isError: tr.isError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: normalized,
|
||||||
|
total,
|
||||||
|
hasMore,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
tokenUsage,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
353
server/providers/cursor/adapter.js
Normal file
353
server/providers/cursor/adapter.js
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* Cursor provider adapter.
|
||||||
|
*
|
||||||
|
* Normalizes Cursor CLI session history into NormalizedMessage format.
|
||||||
|
* @module adapters/cursor
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||||
|
|
||||||
|
const PROVIDER = 'cursor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
|
||||||
|
* and return sorted message blobs in chronological order.
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
|
||||||
|
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
|
||||||
|
*/
|
||||||
|
async function loadCursorBlobs(sessionId, projectPath) {
|
||||||
|
// Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable
|
||||||
|
const { default: sqlite3 } = await import('sqlite3');
|
||||||
|
const { open } = await import('sqlite');
|
||||||
|
|
||||||
|
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||||
|
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
||||||
|
|
||||||
|
const db = await open({
|
||||||
|
filename: storeDbPath,
|
||||||
|
driver: sqlite3.Database,
|
||||||
|
mode: sqlite3.OPEN_READONLY,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs');
|
||||||
|
|
||||||
|
const blobMap = new Map();
|
||||||
|
const parentRefs = new Map();
|
||||||
|
const childRefs = new Map();
|
||||||
|
const jsonBlobs = [];
|
||||||
|
|
||||||
|
for (const blob of allBlobs) {
|
||||||
|
blobMap.set(blob.id, blob);
|
||||||
|
|
||||||
|
if (blob.data && blob.data[0] === 0x7B) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(blob.data.toString('utf8'));
|
||||||
|
jsonBlobs.push({ ...blob, parsed });
|
||||||
|
} catch {
|
||||||
|
// skip unparseable blobs
|
||||||
|
}
|
||||||
|
} else if (blob.data) {
|
||||||
|
const parents = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < blob.data.length - 33) {
|
||||||
|
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
|
||||||
|
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
|
||||||
|
if (blobMap.has(parentHash)) {
|
||||||
|
parents.push(parentHash);
|
||||||
|
}
|
||||||
|
i += 34;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parents.length > 0) {
|
||||||
|
parentRefs.set(blob.id, parents);
|
||||||
|
for (const parentId of parents) {
|
||||||
|
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
|
||||||
|
childRefs.get(parentId).push(blob.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topological sort (DFS)
|
||||||
|
const visited = new Set();
|
||||||
|
const sorted = [];
|
||||||
|
function visit(nodeId) {
|
||||||
|
if (visited.has(nodeId)) return;
|
||||||
|
visited.add(nodeId);
|
||||||
|
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
|
||||||
|
const b = blobMap.get(nodeId);
|
||||||
|
if (b) sorted.push(b);
|
||||||
|
}
|
||||||
|
for (const blob of allBlobs) {
|
||||||
|
if (!parentRefs.has(blob.id)) visit(blob.id);
|
||||||
|
}
|
||||||
|
for (const blob of allBlobs) visit(blob.id);
|
||||||
|
|
||||||
|
// Order JSON blobs by DAG appearance
|
||||||
|
const messageOrder = new Map();
|
||||||
|
let orderIndex = 0;
|
||||||
|
for (const blob of sorted) {
|
||||||
|
if (blob.data && blob.data[0] !== 0x7B) {
|
||||||
|
for (const jb of jsonBlobs) {
|
||||||
|
try {
|
||||||
|
const idBytes = Buffer.from(jb.id, 'hex');
|
||||||
|
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
|
||||||
|
messageOrder.set(jb.id, orderIndex++);
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
||||||
|
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return oa !== ob ? oa - ob : a.rowid - b.rowid;
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
|
||||||
|
const blob = sortedJsonBlobs[idx];
|
||||||
|
const parsed = blob.parsed;
|
||||||
|
if (!parsed) continue;
|
||||||
|
const role = parsed?.role || parsed?.message?.role;
|
||||||
|
if (role === 'system') continue;
|
||||||
|
messages.push({
|
||||||
|
id: blob.id,
|
||||||
|
sequence: idx + 1,
|
||||||
|
rowid: blob.rowid,
|
||||||
|
content: parsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
|
||||||
|
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
|
||||||
|
* @param {object|string} raw - A parsed NDJSON event or a raw text line
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns {import('../types.js').NormalizedMessage[]}
|
||||||
|
*/
|
||||||
|
export function normalizeMessage(raw, sessionId) {
|
||||||
|
// Structured assistant message with content array
|
||||||
|
if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {
|
||||||
|
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];
|
||||||
|
}
|
||||||
|
// Plain string line (non-JSON output)
|
||||||
|
if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('../types.js').ProviderAdapter}
|
||||||
|
*/
|
||||||
|
export const cursorAdapter = {
|
||||||
|
normalizeMessage,
|
||||||
|
/**
|
||||||
|
* Fetch session history for Cursor from SQLite store.db.
|
||||||
|
*/
|
||||||
|
async fetchHistory(sessionId, opts = {}) {
|
||||||
|
const { projectPath = '', limit = null, offset = 0 } = opts;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blobs = await loadCursorBlobs(sessionId, projectPath);
|
||||||
|
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
if (limit !== null && limit > 0) {
|
||||||
|
const start = offset;
|
||||||
|
const page = allNormalized.slice(start, start + limit);
|
||||||
|
return {
|
||||||
|
messages: page,
|
||||||
|
total: allNormalized.length,
|
||||||
|
hasMore: start + limit < allNormalized.length,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: allNormalized,
|
||||||
|
total: allNormalized.length,
|
||||||
|
hasMore: false,
|
||||||
|
offset: 0,
|
||||||
|
limit: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// DB doesn't exist or is unreadable — return empty
|
||||||
|
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||||
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize raw Cursor blob messages into NormalizedMessage[].
|
||||||
|
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns {import('../types.js').NormalizedMessage[]}
|
||||||
|
*/
|
||||||
|
normalizeCursorBlobs(blobs, sessionId) {
|
||||||
|
const messages = [];
|
||||||
|
const toolUseMap = new Map();
|
||||||
|
|
||||||
|
// Use a fixed base timestamp so messages have stable, monotonically-increasing
|
||||||
|
// timestamps based on their sequence number rather than wall-clock time.
|
||||||
|
const baseTime = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < blobs.length; i++) {
|
||||||
|
const blob = blobs[i];
|
||||||
|
const content = blob.content;
|
||||||
|
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
|
||||||
|
const baseId = blob.id || generateMessageId('cursor');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!content?.role || !content?.content) {
|
||||||
|
// Try nested message format
|
||||||
|
if (content?.message?.role && content?.message?.content) {
|
||||||
|
if (content.message.role === 'system') continue;
|
||||||
|
const role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||||
|
let text = '';
|
||||||
|
if (Array.isArray(content.message.content)) {
|
||||||
|
text = content.message.content
|
||||||
|
.map(p => typeof p === 'string' ? p : p?.text || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
} else if (typeof content.message.content === 'string') {
|
||||||
|
text = content.message.content;
|
||||||
|
}
|
||||||
|
if (text?.trim()) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role,
|
||||||
|
content: text,
|
||||||
|
sequence: blob.sequence,
|
||||||
|
rowid: blob.rowid,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.role === 'system') continue;
|
||||||
|
|
||||||
|
// Tool results
|
||||||
|
if (content.role === 'tool') {
|
||||||
|
const toolItems = Array.isArray(content.content) ? content.content : [];
|
||||||
|
for (const item of toolItems) {
|
||||||
|
if (item?.type !== 'tool-result') continue;
|
||||||
|
const toolCallId = item.toolCallId || content.id;
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_tr`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolId: toolCallId,
|
||||||
|
content: item.result || '',
|
||||||
|
isError: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = content.role === 'user' ? 'user' : 'assistant';
|
||||||
|
|
||||||
|
if (Array.isArray(content.content)) {
|
||||||
|
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
||||||
|
const part = content.content[partIdx];
|
||||||
|
|
||||||
|
if (part?.type === 'text' && part?.text) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIdx}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role,
|
||||||
|
content: part.text,
|
||||||
|
sequence: blob.sequence,
|
||||||
|
rowid: blob.rowid,
|
||||||
|
}));
|
||||||
|
} else if (part?.type === 'reasoning' && part?.text) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIdx}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content: part.text,
|
||||||
|
}));
|
||||||
|
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||||
|
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
|
||||||
|
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
|
||||||
|
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIdx}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName,
|
||||||
|
toolInput: part.args || part.input,
|
||||||
|
toolId,
|
||||||
|
}));
|
||||||
|
toolUseMap.set(toolId, messages[messages.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof content.content === 'string' && content.content.trim()) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role,
|
||||||
|
content: content.content,
|
||||||
|
sequence: blob.sequence,
|
||||||
|
rowid: blob.rowid,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error normalizing cursor blob:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach tool results to tool_use messages
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
|
||||||
|
const toolUse = toolUseMap.get(msg.toolId);
|
||||||
|
toolUse.toolResult = {
|
||||||
|
content: msg.content,
|
||||||
|
isError: msg.isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by sequence/rowid
|
||||||
|
messages.sort((a, b) => {
|
||||||
|
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
|
||||||
|
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
|
||||||
|
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
},
|
||||||
|
};
|
||||||
186
server/providers/gemini/adapter.js
Normal file
186
server/providers/gemini/adapter.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Gemini provider adapter.
|
||||||
|
*
|
||||||
|
* Normalizes Gemini CLI session history into NormalizedMessage format.
|
||||||
|
* @module adapters/gemini
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sessionManager from '../../sessionManager.js';
|
||||||
|
import { getGeminiCliSessionMessages } from '../../projects.js';
|
||||||
|
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||||
|
|
||||||
|
const PROVIDER = 'gemini';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
|
||||||
|
* Handles: message (delta/final), tool_use, tool_result, result, error.
|
||||||
|
* @param {object} raw - A parsed NDJSON event
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns {import('../types.js').NormalizedMessage[]}
|
||||||
|
*/
|
||||||
|
export function normalizeMessage(raw, sessionId) {
|
||||||
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
|
const baseId = raw.uuid || generateMessageId('gemini');
|
||||||
|
|
||||||
|
if (raw.type === 'message' && raw.role === 'assistant') {
|
||||||
|
const content = raw.content || '';
|
||||||
|
const msgs = [];
|
||||||
|
if (content) {
|
||||||
|
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
|
||||||
|
}
|
||||||
|
// If not a delta, also send stream_end
|
||||||
|
if (raw.delta !== true) {
|
||||||
|
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
|
||||||
|
}
|
||||||
|
return msgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === 'tool_use') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},
|
||||||
|
toolId: raw.tool_id || baseId,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === 'tool_result') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'tool_result', toolId: raw.tool_id || '',
|
||||||
|
content: raw.output === undefined ? '' : String(raw.output),
|
||||||
|
isError: raw.status === 'error',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === 'result') {
|
||||||
|
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') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||||
|
kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('../types.js').ProviderAdapter}
|
||||||
|
*/
|
||||||
|
export const geminiAdapter = {
|
||||||
|
normalizeMessage,
|
||||||
|
/**
|
||||||
|
* Fetch session history for Gemini.
|
||||||
|
* First tries in-memory session manager, then falls back to CLI sessions on disk.
|
||||||
|
*/
|
||||||
|
async fetchHistory(sessionId, opts = {}) {
|
||||||
|
let rawMessages;
|
||||||
|
try {
|
||||||
|
rawMessages = sessionManager.getSessionMessages(sessionId);
|
||||||
|
|
||||||
|
// Fallback to Gemini CLI sessions on disk
|
||||||
|
if (rawMessages.length === 0) {
|
||||||
|
rawMessages = await getGeminiCliSessionMessages(sessionId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||||
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = [];
|
||||||
|
for (let i = 0; i < rawMessages.length; i++) {
|
||||||
|
const raw = rawMessages[i];
|
||||||
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
|
const baseId = raw.uuid || generateMessageId('gemini');
|
||||||
|
|
||||||
|
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
|
||||||
|
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
|
||||||
|
const role = raw.message?.role || raw.role;
|
||||||
|
const content = raw.message?.content || raw.content;
|
||||||
|
|
||||||
|
if (!role || !content) continue;
|
||||||
|
|
||||||
|
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||||
|
const part = content[partIdx];
|
||||||
|
if (part.type === 'text' && part.text) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIdx}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: normalizedRole,
|
||||||
|
content: part.text,
|
||||||
|
}));
|
||||||
|
} else if (part.type === 'tool_use') {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIdx}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: part.name,
|
||||||
|
toolInput: part.input,
|
||||||
|
toolId: part.id || generateMessageId('gemini_tool'),
|
||||||
|
}));
|
||||||
|
} else if (part.type === 'tool_result') {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIdx}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolId: part.tool_use_id || '',
|
||||||
|
content: part.content === undefined ? '' : String(part.content),
|
||||||
|
isError: Boolean(part.is_error),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof content === 'string' && content.trim()) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: normalizedRole,
|
||||||
|
content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach tool results to tool_use messages
|
||||||
|
const toolResultMap = new Map();
|
||||||
|
for (const msg of normalized) {
|
||||||
|
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||||
|
toolResultMap.set(msg.toolId, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const msg of normalized) {
|
||||||
|
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||||
|
const tr = toolResultMap.get(msg.toolId);
|
||||||
|
msg.toolResult = { content: tr.content, isError: tr.isError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: normalized,
|
||||||
|
total: normalized.length,
|
||||||
|
hasMore: false,
|
||||||
|
offset: 0,
|
||||||
|
limit: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
44
server/providers/registry.js
Normal file
44
server/providers/registry.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Provider Registry
|
||||||
|
*
|
||||||
|
* Centralizes provider adapter lookup. All code that needs a provider adapter
|
||||||
|
* should go through this registry instead of importing individual adapters directly.
|
||||||
|
*
|
||||||
|
* @module providers/registry
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { claudeAdapter } from './claude/adapter.js';
|
||||||
|
import { cursorAdapter } from './cursor/adapter.js';
|
||||||
|
import { codexAdapter } from './codex/adapter.js';
|
||||||
|
import { geminiAdapter } from './gemini/adapter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
|
||||||
|
* @typedef {import('./types.js').SessionProvider} SessionProvider
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Map<string, ProviderAdapter>} */
|
||||||
|
const providers = new Map();
|
||||||
|
|
||||||
|
// Register built-in providers
|
||||||
|
providers.set('claude', claudeAdapter);
|
||||||
|
providers.set('cursor', cursorAdapter);
|
||||||
|
providers.set('codex', codexAdapter);
|
||||||
|
providers.set('gemini', geminiAdapter);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a provider adapter by name.
|
||||||
|
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
|
||||||
|
* @returns {ProviderAdapter | undefined}
|
||||||
|
*/
|
||||||
|
export function getProvider(name) {
|
||||||
|
return providers.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered provider names.
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function getAllProviders() {
|
||||||
|
return Array.from(providers.keys());
|
||||||
|
}
|
||||||
119
server/providers/types.js
Normal file
119
server/providers/types.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Provider Types & Interface
|
||||||
|
*
|
||||||
|
* Defines the normalized message format and the provider adapter interface.
|
||||||
|
* All providers normalize their native formats into NormalizedMessage
|
||||||
|
* before sending over REST or WebSocket.
|
||||||
|
*
|
||||||
|
* @module providers/types
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Session Provider ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Message Kind ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'
|
||||||
|
* | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'
|
||||||
|
* | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── NormalizedMessage ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} NormalizedMessage
|
||||||
|
* @property {string} id - Unique message id (for dedup between server + realtime)
|
||||||
|
* @property {string} sessionId
|
||||||
|
* @property {string} timestamp - ISO 8601
|
||||||
|
* @property {SessionProvider} provider
|
||||||
|
* @property {MessageKind} kind
|
||||||
|
*
|
||||||
|
* Additional fields depending on kind:
|
||||||
|
* - text: role ('user'|'assistant'), content, images?
|
||||||
|
* - tool_use: toolName, toolInput, toolId
|
||||||
|
* - tool_result: toolId, content, isError
|
||||||
|
* - thinking: content
|
||||||
|
* - stream_delta: content
|
||||||
|
* - stream_end: (no extra fields)
|
||||||
|
* - error: content
|
||||||
|
* - complete: (no extra fields)
|
||||||
|
* - status: text, tokens?, canInterrupt?
|
||||||
|
* - permission_request: requestId, toolName, input, context?
|
||||||
|
* - permission_cancelled: requestId
|
||||||
|
* - session_created: newSessionId
|
||||||
|
* - interactive_prompt: content
|
||||||
|
* - task_notification: status, summary
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Fetch History ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FetchHistoryOptions
|
||||||
|
* @property {string} [projectName] - Project name (required for Claude)
|
||||||
|
* @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)
|
||||||
|
* @property {number|null} [limit] - Page size (null = all messages)
|
||||||
|
* @property {number} [offset] - Pagination offset (default: 0)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FetchHistoryResult
|
||||||
|
* @property {NormalizedMessage[]} messages - Normalized messages
|
||||||
|
* @property {number} total - Total number of messages in the session
|
||||||
|
* @property {boolean} hasMore - Whether more messages exist before the current page
|
||||||
|
* @property {number} offset - Current offset
|
||||||
|
* @property {number|null} limit - Page size used
|
||||||
|
* @property {object} [tokenUsage] - Token usage data (provider-specific)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every provider adapter MUST implement this interface.
|
||||||
|
*
|
||||||
|
* @typedef {Object} ProviderAdapter
|
||||||
|
*
|
||||||
|
* @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory
|
||||||
|
* Read persisted session messages from disk/database and return them as NormalizedMessage[].
|
||||||
|
* The backend calls this from the unified GET /api/sessions/:id/messages endpoint.
|
||||||
|
*
|
||||||
|
* Provider implementations:
|
||||||
|
* - Claude: reads ~/.claude/projects/{projectName}/*.jsonl
|
||||||
|
* - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)
|
||||||
|
* - Codex: reads ~/.codex/sessions/*.jsonl
|
||||||
|
* - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files
|
||||||
|
*
|
||||||
|
* @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage
|
||||||
|
* Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].
|
||||||
|
* Used by provider files to convert both history and realtime events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique message ID.
|
||||||
|
* Uses crypto.randomUUID() to avoid collisions across server restarts and workers.
|
||||||
|
* @param {string} [prefix='msg'] - Optional prefix
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function generateMessageId(prefix = 'msg') {
|
||||||
|
return `${prefix}_${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a NormalizedMessage with common fields pre-filled.
|
||||||
|
* @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields
|
||||||
|
* @returns {NormalizedMessage}
|
||||||
|
*/
|
||||||
|
export function createNormalizedMessage(fields) {
|
||||||
|
return {
|
||||||
|
...fields,
|
||||||
|
id: fields.id || generateMessageId(fields.kind),
|
||||||
|
sessionId: fields.sessionId || '',
|
||||||
|
timestamp: fields.timestamp || new Date().toISOString(),
|
||||||
|
provider: fields.provider,
|
||||||
|
};
|
||||||
|
}
|
||||||
29
server/providers/utils.js
Normal file
29
server/providers/utils.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Shared provider utilities.
|
||||||
|
*
|
||||||
|
* @module providers/utils
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefixes that indicate internal/system content which should be hidden from the UI.
|
||||||
|
* @type {readonly string[]}
|
||||||
|
*/
|
||||||
|
export const INTERNAL_CONTENT_PREFIXES = Object.freeze([
|
||||||
|
'<command-name>',
|
||||||
|
'<command-message>',
|
||||||
|
'<command-args>',
|
||||||
|
'<local-command-stdout>',
|
||||||
|
'<system-reminder>',
|
||||||
|
'Caveat:',
|
||||||
|
'This session is being continued from a previous',
|
||||||
|
'[Request interrupted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user text content is internal/system that should be skipped.
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isInternalContent(content) {
|
||||||
|
return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import TOML from '@iarna/toml';
|
import TOML from '@iarna/toml';
|
||||||
import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
|
import { getCodexSessions, deleteCodexSession } from '../projects.js';
|
||||||
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
|
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -68,24 +68,6 @@ router.get('/sessions', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/sessions/:sessionId/messages', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { sessionId } = req.params;
|
|
||||||
const { limit, offset } = req.query;
|
|
||||||
|
|
||||||
const result = await getCodexSessionMessages(
|
|
||||||
sessionId,
|
|
||||||
limit ? parseInt(limit, 10) : null,
|
|
||||||
offset ? parseInt(offset, 10) : 0
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ success: true, ...result });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching Codex session messages:', error);
|
|
||||||
res.status(500).json({ success: false, error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
|
|||||||
@@ -1,39 +1,9 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import sessionManager from '../sessionManager.js';
|
import sessionManager from '../sessionManager.js';
|
||||||
import { sessionNamesDb } from '../database/db.js';
|
import { sessionNamesDb } from '../database/db.js';
|
||||||
import { getGeminiCliSessionMessages } from '../projects.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/sessions/:sessionId/messages', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { sessionId } = req.params;
|
|
||||||
|
|
||||||
if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
|
|
||||||
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let messages = sessionManager.getSessionMessages(sessionId);
|
|
||||||
|
|
||||||
// Fallback to Gemini CLI sessions on disk
|
|
||||||
if (messages.length === 0) {
|
|
||||||
messages = await getGeminiCliSessionMessages(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
messages: messages,
|
|
||||||
total: messages.length,
|
|
||||||
hasMore: false,
|
|
||||||
offset: 0,
|
|
||||||
limit: messages.length
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching Gemini session messages:', error);
|
|
||||||
res.status(500).json({ success: false, error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
|
|||||||
61
server/routes/messages.js
Normal file
61
server/routes/messages.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Unified messages endpoint.
|
||||||
|
*
|
||||||
|
* GET /api/sessions/:sessionId/messages?provider=claude&projectName=foo&limit=50&offset=0
|
||||||
|
*
|
||||||
|
* Replaces the four provider-specific session message endpoints with a single route
|
||||||
|
* that delegates to the appropriate adapter via the provider registry.
|
||||||
|
*
|
||||||
|
* @module routes/messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { getProvider, getAllProviders } from '../providers/registry.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/sessions/:sessionId/messages
|
||||||
|
*
|
||||||
|
* Auth: authenticateToken applied at mount level in index.js
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* provider - 'claude' | 'cursor' | 'codex' | 'gemini' (default: 'claude')
|
||||||
|
* projectName - required for claude provider
|
||||||
|
* projectPath - required for cursor provider (absolute path used for cwdId hash)
|
||||||
|
* limit - page size (omit or null for all)
|
||||||
|
* offset - pagination offset (default: 0)
|
||||||
|
*/
|
||||||
|
router.get('/:sessionId/messages', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const provider = req.query.provider || 'claude';
|
||||||
|
const projectName = req.query.projectName || '';
|
||||||
|
const projectPath = req.query.projectPath || '';
|
||||||
|
const limitParam = req.query.limit;
|
||||||
|
const limit = limitParam !== undefined && limitParam !== null && limitParam !== ''
|
||||||
|
? parseInt(limitParam, 10)
|
||||||
|
: null;
|
||||||
|
const offset = parseInt(req.query.offset || '0', 10);
|
||||||
|
|
||||||
|
const adapter = getProvider(provider);
|
||||||
|
if (!adapter) {
|
||||||
|
const available = getAllProviders().join(', ');
|
||||||
|
return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await adapter.fetchHistory(sessionId, {
|
||||||
|
projectName,
|
||||||
|
projectPath,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching unified messages:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch messages' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -52,8 +52,9 @@ interface UseChatComposerStateArgs {
|
|||||||
onShowSettings?: () => void;
|
onShowSettings?: () => void;
|
||||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
addMessage: (msg: ChatMessage) => void;
|
||||||
setSessionMessages?: Dispatch<SetStateAction<any[]>>;
|
clearMessages: () => void;
|
||||||
|
rewindMessages: (count: number) => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
setCanAbortSession: (canAbort: boolean) => void;
|
setCanAbortSession: (canAbort: boolean) => void;
|
||||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||||
@@ -123,8 +124,9 @@ export function useChatComposerState({
|
|||||||
onShowSettings,
|
onShowSettings,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
setChatMessages,
|
addMessage,
|
||||||
setSessionMessages,
|
clearMessages,
|
||||||
|
rewindMessages,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
@@ -155,69 +157,50 @@ export function useChatComposerState({
|
|||||||
const { action, data } = result;
|
const { action, data } = result;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'clear':
|
case 'clear':
|
||||||
setChatMessages([]);
|
clearMessages();
|
||||||
setSessionMessages?.([]);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'help':
|
case 'help':
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'assistant',
|
||||||
{
|
content: data.content,
|
||||||
type: 'assistant',
|
timestamp: Date.now(),
|
||||||
content: data.content,
|
});
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'model':
|
case 'model':
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'assistant',
|
||||||
{
|
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
|
||||||
type: 'assistant',
|
timestamp: Date.now(),
|
||||||
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
|
});
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cost': {
|
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}`;
|
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}`;
|
||||||
setChatMessages((previous) => [
|
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
|
||||||
...previous,
|
|
||||||
{ type: 'assistant', content: costMessage, timestamp: Date.now() },
|
|
||||||
]);
|
|
||||||
break;
|
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}`;
|
||||||
setChatMessages((previous) => [
|
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
|
||||||
...previous,
|
|
||||||
{ type: 'assistant', content: statusMessage, timestamp: Date.now() },
|
|
||||||
]);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'memory':
|
case 'memory':
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'assistant',
|
||||||
{
|
content: `Warning: ${data.message}`,
|
||||||
type: 'assistant',
|
timestamp: Date.now(),
|
||||||
content: `⚠️ ${data.message}`,
|
});
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'assistant',
|
||||||
{
|
content: `${data.message}\n\nPath: \`${data.path}\``,
|
||||||
type: 'assistant',
|
timestamp: Date.now(),
|
||||||
content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
|
});
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
if (data.exists && onFileOpen) {
|
if (data.exists && onFileOpen) {
|
||||||
onFileOpen(data.path);
|
onFileOpen(data.path);
|
||||||
}
|
}
|
||||||
@@ -230,24 +213,18 @@ export function useChatComposerState({
|
|||||||
|
|
||||||
case 'rewind':
|
case 'rewind':
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'assistant',
|
||||||
{
|
content: `Warning: ${data.message}`,
|
||||||
type: 'assistant',
|
timestamp: Date.now(),
|
||||||
content: `⚠️ ${data.message}`,
|
});
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
setChatMessages((previous) => previous.slice(0, -data.steps * 2));
|
rewindMessages(data.steps * 2);
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'assistant',
|
||||||
{
|
content: `Rewound ${data.steps} step(s). ${data.message}`,
|
||||||
type: 'assistant',
|
timestamp: Date.now(),
|
||||||
content: `⏪ ${data.message}`,
|
});
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -255,7 +232,7 @@ export function useChatComposerState({
|
|||||||
console.warn('Unknown built-in command action:', action);
|
console.warn('Unknown built-in command action:', action);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onFileOpen, onShowSettings, setChatMessages, setSessionMessages],
|
[onFileOpen, onShowSettings, addMessage, clearMessages, rewindMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
|
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
|
||||||
@@ -266,14 +243,11 @@ export function useChatComposerState({
|
|||||||
'This command contains bash commands that will be executed. Do you want to proceed?',
|
'This command contains bash commands that will be executed. Do you want to proceed?',
|
||||||
);
|
);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'assistant',
|
||||||
{
|
content: 'Command execution cancelled',
|
||||||
type: 'assistant',
|
timestamp: Date.now(),
|
||||||
content: '❌ Command execution cancelled',
|
});
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,7 +262,7 @@ export function useChatComposerState({
|
|||||||
handleSubmitRef.current(createFakeSubmitEvent());
|
handleSubmitRef.current(createFakeSubmitEvent());
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [setChatMessages]);
|
}, [addMessage]);
|
||||||
|
|
||||||
const executeCommand = useCallback(
|
const executeCommand = useCallback(
|
||||||
async (command: SlashCommand, rawInput?: string) => {
|
async (command: SlashCommand, rawInput?: string) => {
|
||||||
@@ -346,14 +320,11 @@ export function useChatComposerState({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error executing command:', error);
|
console.error('Error executing command:', error);
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'assistant',
|
||||||
{
|
content: `Error executing command: ${message}`,
|
||||||
type: 'assistant',
|
timestamp: Date.now(),
|
||||||
content: `Error executing command: ${message}`,
|
});
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -367,7 +338,7 @@ export function useChatComposerState({
|
|||||||
input,
|
input,
|
||||||
provider,
|
provider,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
setChatMessages,
|
addMessage,
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -547,18 +518,19 @@ export function useChatComposerState({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Image upload failed:', error);
|
console.error('Image upload failed:', error);
|
||||||
setChatMessages((previous) => [
|
addMessage({
|
||||||
...previous,
|
type: 'error',
|
||||||
{
|
content: `Failed to upload images: ${message}`,
|
||||||
type: 'error',
|
timestamp: new Date(),
|
||||||
content: `Failed to upload images: ${message}`,
|
});
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveSessionId =
|
||||||
|
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||||
|
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
content: currentInput,
|
content: currentInput,
|
||||||
@@ -566,7 +538,7 @@ export function useChatComposerState({
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setChatMessages((previous) => [...previous, userMessage]);
|
addMessage(userMessage);
|
||||||
setIsLoading(true); // Processing banner starts
|
setIsLoading(true); // Processing banner starts
|
||||||
setCanAbortSession(true);
|
setCanAbortSession(true);
|
||||||
setClaudeStatus({
|
setClaudeStatus({
|
||||||
@@ -578,10 +550,6 @@ export function useChatComposerState({
|
|||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
setTimeout(() => scrollToBottom(), 100);
|
||||||
|
|
||||||
const effectiveSessionId =
|
|
||||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
|
||||||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
|
||||||
|
|
||||||
if (!effectiveSessionId && !selectedSession?.id) {
|
if (!effectiveSessionId && !selectedSession?.id) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
||||||
@@ -723,7 +691,7 @@ export function useChatComposerState({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setChatMessages,
|
addMessage,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
|
|||||||
183
src/components/chat/hooks/useChatMessages.ts
Normal file
183
src/components/chat/hooks/useChatMessages.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Message normalization utilities.
|
||||||
|
* Converts NormalizedMessage[] from the session store into ChatMessage[] for the UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NormalizedMessage } from '../../../stores/useSessionStore';
|
||||||
|
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
||||||
|
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
||||||
|
* that the existing UI components expect.
|
||||||
|
*
|
||||||
|
* Internal/system content (e.g. <system-reminder>, <command-name>) is already
|
||||||
|
* filtered server-side by the Claude adapter (server/providers/utils.js).
|
||||||
|
*/
|
||||||
|
export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] {
|
||||||
|
const converted: ChatMessage[] = [];
|
||||||
|
|
||||||
|
// First pass: collect tool results for attachment
|
||||||
|
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||||
|
toolResultMap.set(msg.toolId, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
switch (msg.kind) {
|
||||||
|
case 'text': {
|
||||||
|
const content = msg.content || '';
|
||||||
|
if (!content.trim()) continue;
|
||||||
|
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
// Parse task notifications
|
||||||
|
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g;
|
||||||
|
const taskNotifMatch = taskNotifRegex.exec(content);
|
||||||
|
if (taskNotifMatch) {
|
||||||
|
converted.push({
|
||||||
|
type: 'assistant',
|
||||||
|
content: taskNotifMatch[2]?.trim() || 'Background task finished',
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
isTaskNotification: true,
|
||||||
|
taskStatus: taskNotifMatch[1]?.trim() || 'completed',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
converted.push({
|
||||||
|
type: 'user',
|
||||||
|
content: unescapeWithMathProtection(decodeHtmlEntities(content)),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let text = decodeHtmlEntities(content);
|
||||||
|
text = unescapeWithMathProtection(text);
|
||||||
|
text = formatUsageLimitText(text);
|
||||||
|
converted.push({
|
||||||
|
type: 'assistant',
|
||||||
|
content: text,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_use': {
|
||||||
|
const tr = msg.toolResult || (msg.toolId ? toolResultMap.get(msg.toolId) : null);
|
||||||
|
const isSubagentContainer = msg.toolName === 'Task';
|
||||||
|
|
||||||
|
// Build child tools from subagentTools
|
||||||
|
const childTools: SubagentChildTool[] = [];
|
||||||
|
if (isSubagentContainer && msg.subagentTools && Array.isArray(msg.subagentTools)) {
|
||||||
|
for (const tool of msg.subagentTools as any[]) {
|
||||||
|
childTools.push({
|
||||||
|
toolId: tool.toolId,
|
||||||
|
toolName: tool.toolName,
|
||||||
|
toolInput: tool.toolInput,
|
||||||
|
toolResult: tool.toolResult || null,
|
||||||
|
timestamp: new Date(tool.timestamp || Date.now()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResult = tr
|
||||||
|
? {
|
||||||
|
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||||
|
isError: Boolean(tr.isError),
|
||||||
|
toolUseResult: (tr as any).toolUseResult,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
converted.push({
|
||||||
|
type: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
isToolUse: true,
|
||||||
|
toolName: msg.toolName,
|
||||||
|
toolInput: typeof msg.toolInput === 'string' ? msg.toolInput : JSON.stringify(msg.toolInput ?? '', null, 2),
|
||||||
|
toolId: msg.toolId,
|
||||||
|
toolResult,
|
||||||
|
isSubagentContainer,
|
||||||
|
subagentState: isSubagentContainer
|
||||||
|
? {
|
||||||
|
childTools,
|
||||||
|
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
|
||||||
|
isComplete: Boolean(toolResult),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'thinking':
|
||||||
|
if (msg.content?.trim()) {
|
||||||
|
converted.push({
|
||||||
|
type: 'assistant',
|
||||||
|
content: unescapeWithMathProtection(msg.content),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
isThinking: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
converted.push({
|
||||||
|
type: 'error',
|
||||||
|
content: msg.content || 'Unknown error',
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'interactive_prompt':
|
||||||
|
converted.push({
|
||||||
|
type: 'assistant',
|
||||||
|
content: msg.content || '',
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
isInteractivePrompt: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'task_notification':
|
||||||
|
converted.push({
|
||||||
|
type: 'assistant',
|
||||||
|
content: msg.summary || 'Background task update',
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
isTaskNotification: true,
|
||||||
|
taskStatus: msg.status || 'completed',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stream_delta':
|
||||||
|
if (msg.content) {
|
||||||
|
converted.push({
|
||||||
|
type: 'assistant',
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
isStreaming: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// stream_end, complete, status, permission_*, session_created
|
||||||
|
// are control events — not rendered as messages
|
||||||
|
case 'stream_end':
|
||||||
|
case 'complete':
|
||||||
|
case 'status':
|
||||||
|
case 'permission_request':
|
||||||
|
case 'permission_cancelled':
|
||||||
|
case 'session_created':
|
||||||
|
// Skip — these are handled by useChatRealtimeHandlers
|
||||||
|
break;
|
||||||
|
|
||||||
|
// tool_result is handled via attachment to tool_use above
|
||||||
|
case 'tool_result':
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,32 +5,12 @@ export const CLAUDE_SETTINGS_KEY = 'claude-settings';
|
|||||||
export const safeLocalStorage = {
|
export const safeLocalStorage = {
|
||||||
setItem: (key: string, value: string) => {
|
setItem: (key: string, value: string) => {
|
||||||
try {
|
try {
|
||||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
if (Array.isArray(parsed) && parsed.length > 50) {
|
|
||||||
const truncated = parsed.slice(-50);
|
|
||||||
value = JSON.stringify(truncated);
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
console.warn('Could not parse chat messages for truncation:', parseError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.name === 'QuotaExceededError') {
|
if (error?.name === 'QuotaExceededError') {
|
||||||
console.warn('localStorage quota exceeded, clearing old data');
|
console.warn('localStorage quota exceeded, clearing old data');
|
||||||
|
|
||||||
const keys = Object.keys(localStorage);
|
const keys = Object.keys(localStorage);
|
||||||
const chatKeys = keys.filter((k) => k.startsWith('chat_messages_')).sort();
|
|
||||||
|
|
||||||
if (chatKeys.length > 3) {
|
|
||||||
chatKeys.slice(0, chatKeys.length - 3).forEach((k) => {
|
|
||||||
localStorage.removeItem(k);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
|
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
|
||||||
draftKeys.forEach((k) => {
|
draftKeys.forEach((k) => {
|
||||||
localStorage.removeItem(k);
|
localStorage.removeItem(k);
|
||||||
@@ -40,17 +20,6 @@ export const safeLocalStorage = {
|
|||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
||||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
if (Array.isArray(parsed) && parsed.length > 10) {
|
|
||||||
const minimal = parsed.slice(-10);
|
|
||||||
localStorage.setItem(key, JSON.stringify(minimal));
|
|
||||||
}
|
|
||||||
} catch (finalError) {
|
|
||||||
console.error('Final save attempt failed:', finalError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('localStorage error:', error);
|
console.error('localStorage error:', error);
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import type { ChatMessage } from '../types/types';
|
|
||||||
import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
|
|
||||||
|
|
||||||
export interface DiffLine {
|
export interface DiffLine {
|
||||||
type: 'added' | 'removed';
|
type: 'added' | 'removed';
|
||||||
content: string;
|
content: string;
|
||||||
@@ -9,80 +6,6 @@ export interface DiffLine {
|
|||||||
|
|
||||||
export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];
|
export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];
|
||||||
|
|
||||||
type CursorBlob = {
|
|
||||||
id?: string;
|
|
||||||
sequence?: number;
|
|
||||||
rowid?: number;
|
|
||||||
content?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
|
|
||||||
|
|
||||||
const normalizeToolInput = (value: unknown): string => {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
|
|
||||||
/<user_info>[\s\S]*?<\/user_info>/gi,
|
|
||||||
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
|
|
||||||
/<available_skills>[\s\S]*?<\/available_skills>/gi,
|
|
||||||
/<environment_context>[\s\S]*?<\/environment_context>/gi,
|
|
||||||
/<environment_info>[\s\S]*?<\/environment_info>/gi,
|
|
||||||
];
|
|
||||||
|
|
||||||
const extractCursorUserQuery = (rawText: string): string => {
|
|
||||||
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
|
|
||||||
if (userQueryMatches.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return userQueryMatches
|
|
||||||
.map((match) => (match[1] || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n')
|
|
||||||
.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeCursorUserMessageText = (rawText: string): string => {
|
|
||||||
const decodedText = decodeHtmlEntities(rawText || '').trim();
|
|
||||||
if (!decodedText) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
|
|
||||||
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
|
|
||||||
const extractedUserQuery = extractCursorUserQuery(decodedText);
|
|
||||||
if (extractedUserQuery) {
|
|
||||||
return extractedUserQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitizedText = decodedText;
|
|
||||||
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
|
|
||||||
sanitizedText = sanitizedText.replace(pattern, '');
|
|
||||||
});
|
|
||||||
|
|
||||||
return sanitizedText.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
|
||||||
if (!filePath) {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
return filePath.startsWith('/') ? filePath : `${projectPath}/${filePath}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {
|
export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {
|
||||||
const oldLines = oldStr.split('\n');
|
const oldLines = oldStr.split('\n');
|
||||||
const newLines = newStr.split('\n');
|
const newLines = newStr.split('\n');
|
||||||
@@ -162,434 +85,3 @@ export const createCachedDiffCalculator = (): DiffCalculator => {
|
|||||||
return calculated;
|
return calculated;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: string): ChatMessage[] => {
|
|
||||||
const converted: ChatMessage[] = [];
|
|
||||||
const toolUseMap: Record<string, ChatMessage> = {};
|
|
||||||
|
|
||||||
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx += 1) {
|
|
||||||
const blob = blobs[blobIdx];
|
|
||||||
const content = blob.content;
|
|
||||||
let text = '';
|
|
||||||
let role: ChatMessage['type'] = 'assistant';
|
|
||||||
let reasoningText: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (content?.role && content?.content) {
|
|
||||||
if (content.role === 'system') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.role === 'tool') {
|
|
||||||
const toolItems = asArray<any>(content.content);
|
|
||||||
for (const item of toolItems) {
|
|
||||||
if (item?.type !== 'tool-result') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolName = item.toolName === 'ApplyPatch' ? 'Edit' : item.toolName || 'Unknown Tool';
|
|
||||||
const toolCallId = item.toolCallId || content.id;
|
|
||||||
const result = item.result || '';
|
|
||||||
|
|
||||||
if (toolCallId && toolUseMap[toolCallId]) {
|
|
||||||
toolUseMap[toolCallId].toolResult = {
|
|
||||||
content: result,
|
|
||||||
isError: false,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
converted.push({
|
|
||||||
type: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
||||||
blobId: blob.id,
|
|
||||||
sequence: blob.sequence,
|
|
||||||
rowid: blob.rowid,
|
|
||||||
isToolUse: true,
|
|
||||||
toolName,
|
|
||||||
toolId: toolCallId,
|
|
||||||
toolInput: normalizeToolInput(null),
|
|
||||||
toolResult: {
|
|
||||||
content: result,
|
|
||||||
isError: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
role = content.role === 'user' ? 'user' : 'assistant';
|
|
||||||
|
|
||||||
if (Array.isArray(content.content)) {
|
|
||||||
const textParts: string[] = [];
|
|
||||||
|
|
||||||
for (const part of content.content) {
|
|
||||||
if (part?.type === 'text' && part?.text) {
|
|
||||||
textParts.push(decodeHtmlEntities(part.text));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part?.type === 'reasoning' && part?.text) {
|
|
||||||
reasoningText = decodeHtmlEntities(part.text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
|
||||||
if (textParts.length > 0 || reasoningText) {
|
|
||||||
converted.push({
|
|
||||||
type: role,
|
|
||||||
content: textParts.join('\n'),
|
|
||||||
reasoning: reasoningText ?? undefined,
|
|
||||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
||||||
blobId: blob.id,
|
|
||||||
sequence: blob.sequence,
|
|
||||||
rowid: blob.rowid,
|
|
||||||
});
|
|
||||||
textParts.length = 0;
|
|
||||||
reasoningText = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolNameRaw = part.toolName || part.name || 'Unknown Tool';
|
|
||||||
const toolName = toolNameRaw === 'ApplyPatch' ? 'Edit' : toolNameRaw;
|
|
||||||
const toolId = part.toolCallId || part.id || `tool_${blobIdx}`;
|
|
||||||
let toolInput = part.args || part.input;
|
|
||||||
|
|
||||||
if (toolName === 'Edit' && part.args) {
|
|
||||||
if (part.args.patch) {
|
|
||||||
const patchLines = String(part.args.patch).split('\n');
|
|
||||||
const oldLines: string[] = [];
|
|
||||||
const newLines: string[] = [];
|
|
||||||
let inPatch = false;
|
|
||||||
|
|
||||||
patchLines.forEach((line) => {
|
|
||||||
if (line.startsWith('@@')) {
|
|
||||||
inPatch = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!inPatch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith('-')) {
|
|
||||||
oldLines.push(line.slice(1));
|
|
||||||
} else if (line.startsWith('+')) {
|
|
||||||
newLines.push(line.slice(1));
|
|
||||||
} else if (line.startsWith(' ')) {
|
|
||||||
oldLines.push(line.slice(1));
|
|
||||||
newLines.push(line.slice(1));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
toolInput = {
|
|
||||||
file_path: toAbsolutePath(projectPath, part.args.file_path),
|
|
||||||
old_string: oldLines.join('\n') || part.args.patch,
|
|
||||||
new_string: newLines.join('\n') || part.args.patch,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
toolInput = part.args;
|
|
||||||
}
|
|
||||||
} else if (toolName === 'Read' && part.args) {
|
|
||||||
const filePath = part.args.path || part.args.file_path;
|
|
||||||
toolInput = {
|
|
||||||
file_path: toAbsolutePath(projectPath, filePath),
|
|
||||||
};
|
|
||||||
} else if (toolName === 'Write' && part.args) {
|
|
||||||
const filePath = part.args.path || part.args.file_path;
|
|
||||||
toolInput = {
|
|
||||||
file_path: toAbsolutePath(projectPath, filePath),
|
|
||||||
content: part.args.contents || part.args.content,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolMessage: ChatMessage = {
|
|
||||||
type: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
||||||
blobId: blob.id,
|
|
||||||
sequence: blob.sequence,
|
|
||||||
rowid: blob.rowid,
|
|
||||||
isToolUse: true,
|
|
||||||
toolName,
|
|
||||||
toolId,
|
|
||||||
toolInput: normalizeToolInput(toolInput),
|
|
||||||
toolResult: null,
|
|
||||||
};
|
|
||||||
converted.push(toolMessage);
|
|
||||||
toolUseMap[toolId] = toolMessage;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof part === 'string') {
|
|
||||||
textParts.push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textParts.length > 0) {
|
|
||||||
text = textParts.join('\n');
|
|
||||||
if (reasoningText && !text) {
|
|
||||||
converted.push({
|
|
||||||
type: role,
|
|
||||||
content: '',
|
|
||||||
reasoning: reasoningText,
|
|
||||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
||||||
blobId: blob.id,
|
|
||||||
sequence: blob.sequence,
|
|
||||||
rowid: blob.rowid,
|
|
||||||
});
|
|
||||||
text = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
text = '';
|
|
||||||
}
|
|
||||||
} else if (typeof content.content === 'string') {
|
|
||||||
text = content.content;
|
|
||||||
}
|
|
||||||
} else if (content?.message?.role && content?.message?.content) {
|
|
||||||
if (content.message.role === 'system') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
role = content.message.role === 'user' ? 'user' : 'assistant';
|
|
||||||
if (Array.isArray(content.message.content)) {
|
|
||||||
text = content.message.content
|
|
||||||
.map((part: any) => (typeof part === 'string' ? part : part?.text || ''))
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
} else if (typeof content.message.content === 'string') {
|
|
||||||
text = content.message.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Error parsing blob content:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === 'user') {
|
|
||||||
text = sanitizeCursorUserMessageText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text && text.trim()) {
|
|
||||||
const message: ChatMessage = {
|
|
||||||
type: role,
|
|
||||||
content: text,
|
|
||||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
||||||
blobId: blob.id,
|
|
||||||
sequence: blob.sequence,
|
|
||||||
rowid: blob.rowid,
|
|
||||||
};
|
|
||||||
if (reasoningText) {
|
|
||||||
message.reasoning = reasoningText;
|
|
||||||
}
|
|
||||||
converted.push(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
converted.sort((messageA, messageB) => {
|
|
||||||
if (messageA.sequence !== undefined && messageB.sequence !== undefined) {
|
|
||||||
return Number(messageA.sequence) - Number(messageB.sequence);
|
|
||||||
}
|
|
||||||
if (messageA.rowid !== undefined && messageB.rowid !== undefined) {
|
|
||||||
return Number(messageA.rowid) - Number(messageB.rowid);
|
|
||||||
}
|
|
||||||
return new Date(messageA.timestamp).getTime() - new Date(messageB.timestamp).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
return converted;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
|
||||||
const converted: ChatMessage[] = [];
|
|
||||||
const toolResults = new Map<
|
|
||||||
string,
|
|
||||||
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] }
|
|
||||||
>();
|
|
||||||
|
|
||||||
rawMessages.forEach((message) => {
|
|
||||||
if (message.message?.role === 'user' && Array.isArray(message.message?.content)) {
|
|
||||||
message.message.content.forEach((part: any) => {
|
|
||||||
if (part.type !== 'tool_result') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toolResults.set(part.tool_use_id, {
|
|
||||||
content: part.content,
|
|
||||||
isError: Boolean(part.is_error),
|
|
||||||
timestamp: new Date(message.timestamp || Date.now()),
|
|
||||||
toolUseResult: message.toolUseResult || null,
|
|
||||||
subagentTools: message.subagentTools,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rawMessages.forEach((message) => {
|
|
||||||
if (message.message?.role === 'user' && message.message?.content) {
|
|
||||||
let content = '';
|
|
||||||
if (Array.isArray(message.message.content)) {
|
|
||||||
const textParts: string[] = [];
|
|
||||||
message.message.content.forEach((part: any) => {
|
|
||||||
if (part.type === 'text') {
|
|
||||||
textParts.push(decodeHtmlEntities(part.text));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
content = textParts.join('\n');
|
|
||||||
} else if (typeof message.message.content === 'string') {
|
|
||||||
content = decodeHtmlEntities(message.message.content);
|
|
||||||
} else {
|
|
||||||
content = decodeHtmlEntities(String(message.message.content));
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldSkip =
|
|
||||||
!content ||
|
|
||||||
content.startsWith('<command-name>') ||
|
|
||||||
content.startsWith('<command-message>') ||
|
|
||||||
content.startsWith('<command-args>') ||
|
|
||||||
content.startsWith('<local-command-stdout>') ||
|
|
||||||
content.startsWith('<system-reminder>') ||
|
|
||||||
content.startsWith('Caveat:') ||
|
|
||||||
content.startsWith('This session is being continued from a previous') ||
|
|
||||||
content.startsWith('[Request interrupted');
|
|
||||||
|
|
||||||
if (!shouldSkip) {
|
|
||||||
// Parse <task-notification> blocks into compact system messages
|
|
||||||
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g;
|
|
||||||
const taskNotifMatch = taskNotifRegex.exec(content);
|
|
||||||
if (taskNotifMatch) {
|
|
||||||
const status = taskNotifMatch[1]?.trim() || 'completed';
|
|
||||||
const summary = taskNotifMatch[2]?.trim() || 'Background task finished';
|
|
||||||
converted.push({
|
|
||||||
type: 'assistant',
|
|
||||||
content: summary,
|
|
||||||
timestamp: message.timestamp || new Date().toISOString(),
|
|
||||||
isTaskNotification: true,
|
|
||||||
taskStatus: status,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
converted.push({
|
|
||||||
type: 'user',
|
|
||||||
content: unescapeWithMathProtection(content),
|
|
||||||
timestamp: message.timestamp || new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'thinking' && message.message?.content) {
|
|
||||||
converted.push({
|
|
||||||
type: 'assistant',
|
|
||||||
content: unescapeWithMathProtection(message.message.content),
|
|
||||||
timestamp: message.timestamp || new Date().toISOString(),
|
|
||||||
isThinking: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'tool_use' && message.toolName) {
|
|
||||||
converted.push({
|
|
||||||
type: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: message.timestamp || new Date().toISOString(),
|
|
||||||
isToolUse: true,
|
|
||||||
toolName: message.toolName,
|
|
||||||
toolInput: normalizeToolInput(message.toolInput),
|
|
||||||
toolCallId: message.toolCallId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'tool_result') {
|
|
||||||
for (let index = converted.length - 1; index >= 0; index -= 1) {
|
|
||||||
const convertedMessage = converted[index];
|
|
||||||
if (!convertedMessage.isToolUse || convertedMessage.toolResult) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!message.toolCallId || convertedMessage.toolCallId === message.toolCallId) {
|
|
||||||
convertedMessage.toolResult = {
|
|
||||||
content: message.output || '',
|
|
||||||
isError: false,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.message?.role === 'assistant' && message.message?.content) {
|
|
||||||
if (Array.isArray(message.message.content)) {
|
|
||||||
message.message.content.forEach((part: any) => {
|
|
||||||
if (part.type === 'text') {
|
|
||||||
let text = part.text;
|
|
||||||
if (typeof text === 'string') {
|
|
||||||
text = unescapeWithMathProtection(text);
|
|
||||||
}
|
|
||||||
converted.push({
|
|
||||||
type: 'assistant',
|
|
||||||
content: text,
|
|
||||||
timestamp: message.timestamp || new Date().toISOString(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === 'tool_use') {
|
|
||||||
const toolResult = toolResults.get(part.id);
|
|
||||||
const isSubagentContainer = part.name === 'Task';
|
|
||||||
|
|
||||||
// Build child tools from server-provided subagentTools data
|
|
||||||
const childTools: import('../types/types').SubagentChildTool[] = [];
|
|
||||||
if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) {
|
|
||||||
for (const tool of toolResult.subagentTools as any[]) {
|
|
||||||
childTools.push({
|
|
||||||
toolId: tool.toolId,
|
|
||||||
toolName: tool.toolName,
|
|
||||||
toolInput: tool.toolInput,
|
|
||||||
toolResult: tool.toolResult || null,
|
|
||||||
timestamp: new Date(tool.timestamp || Date.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
converted.push({
|
|
||||||
type: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: message.timestamp || new Date().toISOString(),
|
|
||||||
isToolUse: true,
|
|
||||||
toolName: part.name,
|
|
||||||
toolInput: normalizeToolInput(part.input),
|
|
||||||
toolId: part.id,
|
|
||||||
toolResult: toolResult
|
|
||||||
? {
|
|
||||||
content:
|
|
||||||
typeof toolResult.content === 'string'
|
|
||||||
? toolResult.content
|
|
||||||
: JSON.stringify(toolResult.content),
|
|
||||||
isError: toolResult.isError,
|
|
||||||
toolUseResult: toolResult.toolUseResult,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
toolError: toolResult?.isError || false,
|
|
||||||
toolResultTimestamp: toolResult?.timestamp || new Date(),
|
|
||||||
isSubagentContainer,
|
|
||||||
subagentState: isSubagentContainer
|
|
||||||
? {
|
|
||||||
childTools,
|
|
||||||
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
|
|
||||||
isComplete: Boolean(toolResult),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof message.message.content === 'string') {
|
|
||||||
converted.push({
|
|
||||||
type: 'assistant',
|
|
||||||
content: unescapeWithMathProtection(message.message.content),
|
|
||||||
timestamp: message.timestamp || new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return converted;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
||||||
import type { ChatInterfaceProps, Provider } from '../types/types';
|
import type { ChatInterfaceProps, Provider } from '../types/types';
|
||||||
|
import type { SessionProvider } from '../../../types/app';
|
||||||
import { useChatProviderState } from '../hooks/useChatProviderState';
|
import { useChatProviderState } from '../hooks/useChatProviderState';
|
||||||
import { useChatSessionState } from '../hooks/useChatSessionState';
|
import { useChatSessionState } from '../hooks/useChatSessionState';
|
||||||
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
||||||
import { useChatComposerState } from '../hooks/useChatComposerState';
|
import { useChatComposerState } from '../hooks/useChatComposerState';
|
||||||
|
import { useSessionStore } from '../../../stores/useSessionStore';
|
||||||
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
||||||
import ChatComposer from './subcomponents/ChatComposer';
|
import ChatComposer from './subcomponents/ChatComposer';
|
||||||
|
|
||||||
@@ -43,8 +45,10 @@ function ChatInterface({
|
|||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
|
const sessionStore = useSessionStore();
|
||||||
const streamBufferRef = useRef('');
|
const streamBufferRef = useRef('');
|
||||||
const streamTimerRef = useRef<number | null>(null);
|
const streamTimerRef = useRef<number | null>(null);
|
||||||
|
const accumulatedStreamRef = useRef('');
|
||||||
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
||||||
|
|
||||||
const resetStreamingState = useCallback(() => {
|
const resetStreamingState = useCallback(() => {
|
||||||
@@ -53,6 +57,7 @@ function ChatInterface({
|
|||||||
streamTimerRef.current = null;
|
streamTimerRef.current = null;
|
||||||
}
|
}
|
||||||
streamBufferRef.current = '';
|
streamBufferRef.current = '';
|
||||||
|
accumulatedStreamRef.current = '';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -76,18 +81,17 @@ function ChatInterface({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
chatMessages,
|
chatMessages,
|
||||||
setChatMessages,
|
addMessage,
|
||||||
|
clearMessages,
|
||||||
|
rewindMessages,
|
||||||
isLoading,
|
isLoading,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
sessionMessages,
|
|
||||||
setSessionMessages,
|
|
||||||
isLoadingSessionMessages,
|
isLoadingSessionMessages,
|
||||||
isLoadingMoreMessages,
|
isLoadingMoreMessages,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
setIsSystemSessionChange,
|
|
||||||
canAbortSession,
|
canAbortSession,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
isUserScrolledUp,
|
isUserScrolledUp,
|
||||||
@@ -109,7 +113,6 @@ function ChatInterface({
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollToBottomAndReset,
|
scrollToBottomAndReset,
|
||||||
handleScroll,
|
handleScroll,
|
||||||
loadSessionMessages,
|
|
||||||
} = useChatSessionState({
|
} = useChatSessionState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -120,6 +123,7 @@ function ChatInterface({
|
|||||||
processingSessions,
|
processingSessions,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -189,8 +193,9 @@ function ChatInterface({
|
|||||||
onShowSettings,
|
onShowSettings,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
setChatMessages,
|
addMessage,
|
||||||
setSessionMessages,
|
clearMessages,
|
||||||
|
rewindMessages,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
@@ -198,22 +203,19 @@ function ChatInterface({
|
|||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
// On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed
|
// On WebSocket reconnect, re-fetch the current session's messages from the server
|
||||||
// streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown.
|
// so missed streaming events are shown. Also reset isLoading.
|
||||||
// Also reset isLoading — if the server restarted or the session died mid-stream, the client
|
|
||||||
// would be stuck in "Processing..." forever without this reset.
|
|
||||||
const handleWebSocketReconnect = useCallback(async () => {
|
const handleWebSocketReconnect = useCallback(async () => {
|
||||||
if (!selectedProject || !selectedSession) return;
|
if (!selectedProject || !selectedSession) return;
|
||||||
const provider = (localStorage.getItem('selected-provider') as any) || 'claude';
|
const providerVal = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
|
||||||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider);
|
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||||
if (messages && messages.length > 0) {
|
provider: (selectedSession.__provider || providerVal) as SessionProvider,
|
||||||
setChatMessages(messages);
|
projectName: selectedProject.name,
|
||||||
}
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||||
// Reset loading state — if the session is still active, new WebSocket messages will
|
});
|
||||||
// set it back to true. If it died, this clears the permanent frozen state.
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setCanAbortSession(false);
|
setCanAbortSession(false);
|
||||||
}, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]);
|
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
|
||||||
|
|
||||||
useChatRealtimeHandlers({
|
useChatRealtimeHandlers({
|
||||||
latestMessage,
|
latestMessage,
|
||||||
@@ -222,22 +224,22 @@ function ChatInterface({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
setChatMessages,
|
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setCanAbortSession,
|
setCanAbortSession,
|
||||||
setClaudeStatus,
|
setClaudeStatus,
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
setIsSystemSessionChange,
|
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
pendingViewSessionRef,
|
pendingViewSessionRef,
|
||||||
streamBufferRef,
|
streamBufferRef,
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
|
accumulatedStreamRef,
|
||||||
onSessionInactive,
|
onSessionInactive,
|
||||||
onSessionProcessing,
|
onSessionProcessing,
|
||||||
onSessionNotProcessing,
|
onSessionNotProcessing,
|
||||||
onReplaceTemporarySession,
|
onReplaceTemporarySession,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onWebSocketReconnect: handleWebSocketReconnect,
|
onWebSocketReconnect: handleWebSocketReconnect,
|
||||||
|
sessionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -319,7 +321,7 @@ function ChatInterface({
|
|||||||
isLoadingMoreMessages={isLoadingMoreMessages}
|
isLoadingMoreMessages={isLoadingMoreMessages}
|
||||||
hasMoreMessages={hasMoreMessages}
|
hasMoreMessages={hasMoreMessages}
|
||||||
totalMessages={totalMessages}
|
totalMessages={totalMessages}
|
||||||
sessionMessagesCount={sessionMessages.length}
|
sessionMessagesCount={chatMessages.length}
|
||||||
visibleMessageCount={visibleMessageCount}
|
visibleMessageCount={visibleMessageCount}
|
||||||
visibleMessages={visibleMessages}
|
visibleMessages={visibleMessages}
|
||||||
loadEarlierMessages={loadEarlierMessages}
|
loadEarlierMessages={loadEarlierMessages}
|
||||||
|
|||||||
455
src/stores/useSessionStore.ts
Normal file
455
src/stores/useSessionStore.ts
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* Session-keyed message store.
|
||||||
|
*
|
||||||
|
* Holds per-session state in a Map keyed by sessionId.
|
||||||
|
* Session switch = change activeSessionId pointer. No clearing. Old data stays.
|
||||||
|
* WebSocket handler = store.appendRealtime(msg.sessionId, msg). One line.
|
||||||
|
* No localStorage for messages. Backend JSONL is the source of truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { SessionProvider } from '../types/app';
|
||||||
|
import { authenticatedFetch } from '../utils/api';
|
||||||
|
|
||||||
|
// ─── NormalizedMessage (mirrors server/adapters/types.js) ────────────────────
|
||||||
|
|
||||||
|
export type MessageKind =
|
||||||
|
| 'text'
|
||||||
|
| 'tool_use'
|
||||||
|
| 'tool_result'
|
||||||
|
| 'thinking'
|
||||||
|
| 'stream_delta'
|
||||||
|
| 'stream_end'
|
||||||
|
| 'error'
|
||||||
|
| 'complete'
|
||||||
|
| 'status'
|
||||||
|
| 'permission_request'
|
||||||
|
| 'permission_cancelled'
|
||||||
|
| 'session_created'
|
||||||
|
| 'interactive_prompt'
|
||||||
|
| 'task_notification';
|
||||||
|
|
||||||
|
export interface NormalizedMessage {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
timestamp: string;
|
||||||
|
provider: SessionProvider;
|
||||||
|
kind: MessageKind;
|
||||||
|
|
||||||
|
// kind-specific fields (flat for simplicity)
|
||||||
|
role?: 'user' | 'assistant';
|
||||||
|
content?: string;
|
||||||
|
images?: string[];
|
||||||
|
toolName?: string;
|
||||||
|
toolInput?: unknown;
|
||||||
|
toolId?: string;
|
||||||
|
toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null;
|
||||||
|
isError?: boolean;
|
||||||
|
text?: string;
|
||||||
|
tokens?: number;
|
||||||
|
canInterrupt?: boolean;
|
||||||
|
tokenBudget?: unknown;
|
||||||
|
requestId?: string;
|
||||||
|
input?: unknown;
|
||||||
|
context?: unknown;
|
||||||
|
newSessionId?: string;
|
||||||
|
status?: string;
|
||||||
|
summary?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
actualSessionId?: string;
|
||||||
|
parentToolUseId?: string;
|
||||||
|
subagentTools?: unknown[];
|
||||||
|
isFinal?: boolean;
|
||||||
|
// Cursor-specific ordering
|
||||||
|
sequence?: number;
|
||||||
|
rowid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-session slot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SessionStatus = 'idle' | 'loading' | 'streaming' | 'error';
|
||||||
|
|
||||||
|
export interface SessionSlot {
|
||||||
|
serverMessages: NormalizedMessage[];
|
||||||
|
realtimeMessages: NormalizedMessage[];
|
||||||
|
merged: NormalizedMessage[];
|
||||||
|
/** @internal Cache-invalidation refs for computeMerged */
|
||||||
|
_lastServerRef: NormalizedMessage[];
|
||||||
|
_lastRealtimeRef: NormalizedMessage[];
|
||||||
|
status: SessionStatus;
|
||||||
|
fetchedAt: number;
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
offset: number;
|
||||||
|
tokenUsage: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: NormalizedMessage[] = [];
|
||||||
|
|
||||||
|
function createEmptySlot(): SessionSlot {
|
||||||
|
return {
|
||||||
|
serverMessages: EMPTY,
|
||||||
|
realtimeMessages: EMPTY,
|
||||||
|
merged: EMPTY,
|
||||||
|
_lastServerRef: EMPTY,
|
||||||
|
_lastRealtimeRef: EMPTY,
|
||||||
|
status: 'idle',
|
||||||
|
fetchedAt: 0,
|
||||||
|
total: 0,
|
||||||
|
hasMore: false,
|
||||||
|
offset: 0,
|
||||||
|
tokenUsage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute merged messages: server + realtime, deduped by id.
|
||||||
|
* Server messages take priority (they're the persisted source of truth).
|
||||||
|
* Realtime messages that aren't yet in server stay (in-flight streaming).
|
||||||
|
*/
|
||||||
|
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
|
||||||
|
if (realtime.length === 0) return server;
|
||||||
|
if (server.length === 0) return realtime;
|
||||||
|
const serverIds = new Set(server.map(m => m.id));
|
||||||
|
const extra = realtime.filter(m => !serverIds.has(m.id));
|
||||||
|
if (extra.length === 0) return server;
|
||||||
|
return [...server, ...extra];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute slot.merged only when the input arrays have actually changed
|
||||||
|
* (by reference). Returns true if merged was recomputed.
|
||||||
|
*/
|
||||||
|
function recomputeMergedIfNeeded(slot: SessionSlot): boolean {
|
||||||
|
if (slot.serverMessages === slot._lastServerRef && slot.realtimeMessages === slot._lastRealtimeRef) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
slot._lastServerRef = slot.serverMessages;
|
||||||
|
slot._lastRealtimeRef = slot.realtimeMessages;
|
||||||
|
slot.merged = computeMerged(slot.serverMessages, slot.realtimeMessages);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stale threshold ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STALE_THRESHOLD_MS = 30_000;
|
||||||
|
|
||||||
|
const MAX_REALTIME_MESSAGES = 500;
|
||||||
|
|
||||||
|
// ─── Hook ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useSessionStore() {
|
||||||
|
const storeRef = useRef(new Map<string, SessionSlot>());
|
||||||
|
const activeSessionIdRef = useRef<string | null>(null);
|
||||||
|
// Bump to force re-render — only when the active session's data changes
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
const notify = useCallback((sessionId: string) => {
|
||||||
|
if (sessionId === activeSessionIdRef.current) {
|
||||||
|
setTick(n => n + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActiveSession = useCallback((sessionId: string | null) => {
|
||||||
|
activeSessionIdRef.current = sessionId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getSlot = useCallback((sessionId: string): SessionSlot => {
|
||||||
|
const store = storeRef.current;
|
||||||
|
if (!store.has(sessionId)) {
|
||||||
|
store.set(sessionId, createEmptySlot());
|
||||||
|
}
|
||||||
|
return store.get(sessionId)!;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const has = useCallback((sessionId: string) => storeRef.current.has(sessionId), []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch messages from the unified endpoint and populate serverMessages.
|
||||||
|
*/
|
||||||
|
const fetchFromServer = useCallback(async (
|
||||||
|
sessionId: string,
|
||||||
|
opts: {
|
||||||
|
provider?: SessionProvider;
|
||||||
|
projectName?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
limit?: number | null;
|
||||||
|
offset?: number;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const slot = getSlot(sessionId);
|
||||||
|
slot.status = 'loading';
|
||||||
|
notify(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.provider) params.append('provider', opts.provider);
|
||||||
|
if (opts.projectName) params.append('projectName', opts.projectName);
|
||||||
|
if (opts.projectPath) params.append('projectPath', opts.projectPath);
|
||||||
|
if (opts.limit !== null && opts.limit !== undefined) {
|
||||||
|
params.append('limit', String(opts.limit));
|
||||||
|
params.append('offset', String(opts.offset ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
|
||||||
|
const response = await authenticatedFetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const messages: NormalizedMessage[] = data.messages || [];
|
||||||
|
|
||||||
|
slot.serverMessages = messages;
|
||||||
|
slot.total = data.total ?? messages.length;
|
||||||
|
slot.hasMore = Boolean(data.hasMore);
|
||||||
|
slot.offset = (opts.offset ?? 0) + messages.length;
|
||||||
|
slot.fetchedAt = Date.now();
|
||||||
|
slot.status = 'idle';
|
||||||
|
recomputeMergedIfNeeded(slot);
|
||||||
|
if (data.tokenUsage) {
|
||||||
|
slot.tokenUsage = data.tokenUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(sessionId);
|
||||||
|
return slot;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);
|
||||||
|
slot.status = 'error';
|
||||||
|
notify(sessionId);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load older (paginated) messages and prepend to serverMessages.
|
||||||
|
*/
|
||||||
|
const fetchMore = useCallback(async (
|
||||||
|
sessionId: string,
|
||||||
|
opts: {
|
||||||
|
provider?: SessionProvider;
|
||||||
|
projectName?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const slot = getSlot(sessionId);
|
||||||
|
if (!slot.hasMore) return slot;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.provider) params.append('provider', opts.provider);
|
||||||
|
if (opts.projectName) params.append('projectName', opts.projectName);
|
||||||
|
if (opts.projectPath) params.append('projectPath', opts.projectPath);
|
||||||
|
const limit = opts.limit ?? 20;
|
||||||
|
params.append('limit', String(limit));
|
||||||
|
params.append('offset', String(slot.offset));
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(url);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
const olderMessages: NormalizedMessage[] = data.messages || [];
|
||||||
|
|
||||||
|
// Prepend older messages (they're earlier in the conversation)
|
||||||
|
slot.serverMessages = [...olderMessages, ...slot.serverMessages];
|
||||||
|
slot.hasMore = Boolean(data.hasMore);
|
||||||
|
slot.offset = slot.offset + olderMessages.length;
|
||||||
|
recomputeMergedIfNeeded(slot);
|
||||||
|
notify(sessionId);
|
||||||
|
return slot;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a realtime (WebSocket) message to the correct session slot.
|
||||||
|
* This works regardless of which session is actively viewed.
|
||||||
|
*/
|
||||||
|
const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => {
|
||||||
|
const slot = getSlot(sessionId);
|
||||||
|
let updated = [...slot.realtimeMessages, msg];
|
||||||
|
if (updated.length > MAX_REALTIME_MESSAGES) {
|
||||||
|
updated = updated.slice(-MAX_REALTIME_MESSAGES);
|
||||||
|
}
|
||||||
|
slot.realtimeMessages = updated;
|
||||||
|
recomputeMergedIfNeeded(slot);
|
||||||
|
notify(sessionId);
|
||||||
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append multiple realtime messages at once (batch).
|
||||||
|
*/
|
||||||
|
const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => {
|
||||||
|
if (msgs.length === 0) return;
|
||||||
|
const slot = getSlot(sessionId);
|
||||||
|
let updated = [...slot.realtimeMessages, ...msgs];
|
||||||
|
if (updated.length > MAX_REALTIME_MESSAGES) {
|
||||||
|
updated = updated.slice(-MAX_REALTIME_MESSAGES);
|
||||||
|
}
|
||||||
|
slot.realtimeMessages = updated;
|
||||||
|
recomputeMergedIfNeeded(slot);
|
||||||
|
notify(sessionId);
|
||||||
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetch serverMessages from the unified endpoint (e.g., on projects_updated).
|
||||||
|
*/
|
||||||
|
const refreshFromServer = useCallback(async (
|
||||||
|
sessionId: string,
|
||||||
|
opts: {
|
||||||
|
provider?: SessionProvider;
|
||||||
|
projectName?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const slot = getSlot(sessionId);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.provider) params.append('provider', opts.provider);
|
||||||
|
if (opts.projectName) params.append('projectName', opts.projectName);
|
||||||
|
if (opts.projectPath) params.append('projectPath', opts.projectPath);
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
|
||||||
|
const response = await authenticatedFetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
slot.serverMessages = data.messages || [];
|
||||||
|
slot.total = data.total ?? slot.serverMessages.length;
|
||||||
|
slot.hasMore = Boolean(data.hasMore);
|
||||||
|
slot.fetchedAt = Date.now();
|
||||||
|
// drop realtime messages that the server has caught up with to prevent unbounded growth.
|
||||||
|
slot.realtimeMessages = [];
|
||||||
|
recomputeMergedIfNeeded(slot);
|
||||||
|
notify(sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SessionStore] refresh failed for ${sessionId}:`, error);
|
||||||
|
}
|
||||||
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update session status.
|
||||||
|
*/
|
||||||
|
const setStatus = useCallback((sessionId: string, status: SessionStatus) => {
|
||||||
|
const slot = getSlot(sessionId);
|
||||||
|
slot.status = status;
|
||||||
|
notify(sessionId);
|
||||||
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session's data is stale (>30s old).
|
||||||
|
*/
|
||||||
|
const isStale = useCallback((sessionId: string) => {
|
||||||
|
const slot = storeRef.current.get(sessionId);
|
||||||
|
if (!slot) return true;
|
||||||
|
return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update or create a streaming message (accumulated text so far).
|
||||||
|
* Uses a well-known ID so subsequent calls replace the same message.
|
||||||
|
*/
|
||||||
|
const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: SessionProvider) => {
|
||||||
|
const slot = getSlot(sessionId);
|
||||||
|
const streamId = `__streaming_${sessionId}`;
|
||||||
|
const msg: NormalizedMessage = {
|
||||||
|
id: streamId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: msgProvider,
|
||||||
|
kind: 'stream_delta',
|
||||||
|
content: accumulatedText,
|
||||||
|
};
|
||||||
|
const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
slot.realtimeMessages = [...slot.realtimeMessages];
|
||||||
|
slot.realtimeMessages[idx] = msg;
|
||||||
|
} else {
|
||||||
|
slot.realtimeMessages = [...slot.realtimeMessages, msg];
|
||||||
|
}
|
||||||
|
recomputeMergedIfNeeded(slot);
|
||||||
|
notify(sessionId);
|
||||||
|
}, [getSlot, notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize streaming: convert the streaming message to a regular text message.
|
||||||
|
* The well-known streaming ID is replaced with a unique text message ID.
|
||||||
|
*/
|
||||||
|
const finalizeStreaming = useCallback((sessionId: string) => {
|
||||||
|
const slot = storeRef.current.get(sessionId);
|
||||||
|
if (!slot) return;
|
||||||
|
const streamId = `__streaming_${sessionId}`;
|
||||||
|
const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const stream = slot.realtimeMessages[idx];
|
||||||
|
slot.realtimeMessages = [...slot.realtimeMessages];
|
||||||
|
slot.realtimeMessages[idx] = {
|
||||||
|
...stream,
|
||||||
|
id: `text_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'assistant',
|
||||||
|
};
|
||||||
|
recomputeMergedIfNeeded(slot);
|
||||||
|
notify(sessionId);
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear realtime messages for a session (e.g., after stream completes and server fetch catches up).
|
||||||
|
*/
|
||||||
|
const clearRealtime = useCallback((sessionId: string) => {
|
||||||
|
const slot = storeRef.current.get(sessionId);
|
||||||
|
if (slot) {
|
||||||
|
slot.realtimeMessages = [];
|
||||||
|
recomputeMergedIfNeeded(slot);
|
||||||
|
notify(sessionId);
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get merged messages for a session (for rendering).
|
||||||
|
*/
|
||||||
|
const getMessages = useCallback((sessionId: string): NormalizedMessage[] => {
|
||||||
|
return storeRef.current.get(sessionId)?.merged ?? [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session slot (for status, pagination info, etc.).
|
||||||
|
*/
|
||||||
|
const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => {
|
||||||
|
return storeRef.current.get(sessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
getSlot,
|
||||||
|
has,
|
||||||
|
fetchFromServer,
|
||||||
|
fetchMore,
|
||||||
|
appendRealtime,
|
||||||
|
appendRealtimeBatch,
|
||||||
|
refreshFromServer,
|
||||||
|
setActiveSession,
|
||||||
|
setStatus,
|
||||||
|
isStale,
|
||||||
|
updateStreaming,
|
||||||
|
finalizeStreaming,
|
||||||
|
clearRealtime,
|
||||||
|
getMessages,
|
||||||
|
getSessionSlot,
|
||||||
|
}), [
|
||||||
|
getSlot, has, fetchFromServer, fetchMore,
|
||||||
|
appendRealtime, appendRealtimeBatch, refreshFromServer,
|
||||||
|
setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming,
|
||||||
|
clearRealtime, getMessages, getSessionSlot,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionStore = ReturnType<typeof useSessionStore>;
|
||||||
@@ -54,25 +54,18 @@ export const api = {
|
|||||||
projects: () => authenticatedFetch('/api/projects'),
|
projects: () => authenticatedFetch('/api/projects'),
|
||||||
sessions: (projectName, limit = 5, offset = 0) =>
|
sessions: (projectName, limit = 5, offset = 0) =>
|
||||||
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
|
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
|
||||||
sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => {
|
// Unified endpoint — all providers through one URL
|
||||||
|
unifiedSessionMessages: (sessionId, provider = 'claude', { projectName = '', projectPath = '', limit = null, offset = 0 } = {}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
params.append('provider', provider);
|
||||||
|
if (projectName) params.append('projectName', projectName);
|
||||||
|
if (projectPath) params.append('projectPath', projectPath);
|
||||||
if (limit !== null) {
|
if (limit !== null) {
|
||||||
params.append('limit', limit);
|
params.append('limit', String(limit));
|
||||||
params.append('offset', offset);
|
params.append('offset', String(offset));
|
||||||
}
|
}
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
|
return authenticatedFetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages${queryString ? `?${queryString}` : ''}`);
|
||||||
let url;
|
|
||||||
if (provider === 'codex') {
|
|
||||||
url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
|
||||||
} else if (provider === 'cursor') {
|
|
||||||
url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
|
||||||
} else if (provider === 'gemini') {
|
|
||||||
url = `/api/gemini/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
|
||||||
} else {
|
|
||||||
url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
|
|
||||||
}
|
|
||||||
return authenticatedFetch(url);
|
|
||||||
},
|
},
|
||||||
renameProject: (projectName, displayName) =>
|
renameProject: (projectName, displayName) =>
|
||||||
authenticatedFetch(`/api/projects/${projectName}/rename`, {
|
authenticatedFetch(`/api/projects/${projectName}/rename`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user