mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-17 19:11:33 +00:00
feat: unified message architecture with provider adapters and session store
- Add provider adapter layer (server/providers/) with registry pattern
- Claude, Cursor, Codex, Gemini adapters normalize native formats to NormalizedMessage
- Shared types.js defines ProviderAdapter interface and message kinds
- Registry enables polymorphic provider lookup
- Add unified REST endpoint: GET /api/sessions/:id/messages?provider=...
- Replaces four provider-specific message endpoints with one
- Delegates to provider adapters via registry
- Add frontend session-keyed store (useSessionStore)
- Per-session Map with serverMessages/realtimeMessages/merged
- Dedup by ID, stale threshold for re-fetch, background session accumulation
- No localStorage for messages — backend JSONL is source of truth
- Add normalizedToChatMessages converter (useChatMessages)
- Converts NormalizedMessage[] to existing ChatMessage[] UI format
- Wire unified store into ChatInterface, useChatSessionState, useChatRealtimeHandlers
- Session switch uses store cache for instant render
- Background WebSocket messages routed to correct session slot
This commit is contained in:
@@ -24,6 +24,8 @@ import {
|
||||
notifyRunStopped,
|
||||
notifyUserIfEnabled
|
||||
} from './services/notification-orchestrator.js';
|
||||
import { claudeAdapter } from './providers/claude/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
@@ -142,7 +144,7 @@ function matchesToolPermission(entry, toolName, input) {
|
||||
* @returns {Object} SDK-compatible options
|
||||
*/
|
||||
function mapCliOptionsToSDK(options = {}) {
|
||||
const { sessionId, cwd, toolsSettings, permissionMode, images } = options;
|
||||
const { sessionId, cwd, toolsSettings, permissionMode } = options;
|
||||
|
||||
const sdkOptions = {};
|
||||
|
||||
@@ -193,7 +195,7 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
// Map model (default to sonnet)
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||
console.log(`Using model: ${sdkOptions.model}`);
|
||||
// Model logged at query start below
|
||||
|
||||
// Map system prompt configuration
|
||||
sdkOptions.systemPrompt = {
|
||||
@@ -304,7 +306,7 @@ function extractTokenBudget(resultMessage) {
|
||||
// This is the user's budget limit, not the model's context window
|
||||
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 {
|
||||
used: totalUsed,
|
||||
@@ -360,7 +362,7 @@ async function handleImages(command, images, cwd) {
|
||||
modifiedCommand = command + imageNote;
|
||||
}
|
||||
|
||||
console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
|
||||
// Images processed
|
||||
return { modifiedCommand, tempImagePaths, tempDir };
|
||||
} catch (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) {
|
||||
console.error('Error during temp file cleanup:', error);
|
||||
}
|
||||
@@ -413,7 +415,7 @@ async function loadMcpConfig(cwd) {
|
||||
await fs.access(claudeConfigPath);
|
||||
} catch (error) {
|
||||
// File doesn't exist, return null
|
||||
console.log('No ~/.claude.json found, proceeding without MCP servers');
|
||||
// No config file
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -433,7 +435,7 @@ async function loadMcpConfig(cwd) {
|
||||
// Add global MCP servers
|
||||
if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
|
||||
mcpServers = { ...claudeConfig.mcpServers };
|
||||
console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`);
|
||||
// Global MCP servers loaded
|
||||
}
|
||||
|
||||
// Add/override with project-specific MCP servers
|
||||
@@ -441,17 +443,14 @@ async function loadMcpConfig(cwd) {
|
||||
const projectConfig = claudeConfig.claudeProjects[cwd];
|
||||
if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
|
||||
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
|
||||
if (Object.keys(mcpServers).length === 0) {
|
||||
console.log('No MCP servers configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
|
||||
return mcpServers;
|
||||
} catch (error) {
|
||||
console.error('Error loading MCP config:', error.message);
|
||||
@@ -541,13 +540,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
const requestId = createRequestId();
|
||||
ws.send({
|
||||
type: 'claude-permission-request',
|
||||
requestId,
|
||||
toolName,
|
||||
input,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
@@ -569,12 +562,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
_receivedAt: new Date(),
|
||||
},
|
||||
onCancel: (reason) => {
|
||||
ws.send({
|
||||
type: 'claude-permission-cancelled',
|
||||
requestId,
|
||||
reason,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
}
|
||||
});
|
||||
if (!decision) {
|
||||
@@ -650,39 +638,35 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
// Send session-created event only once for new sessions
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId
|
||||
});
|
||||
} else {
|
||||
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
|
||||
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
|
||||
}
|
||||
} 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);
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: transformedMessage,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
const sid = capturedSessionId || sessionId || null;
|
||||
|
||||
// Use adapter to normalize SDK events into NormalizedMessage[]
|
||||
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
|
||||
if (message.type === 'result') {
|
||||
const models = Object.keys(message.modelUsage || {});
|
||||
if (models.length > 0) {
|
||||
console.log("---> Model was sent using:", models);
|
||||
// Model info available in result message
|
||||
}
|
||||
const tokenBudget = extractTokenBudget(message);
|
||||
if (tokenBudget) {
|
||||
console.log('Token budget from modelUsage:', tokenBudget);
|
||||
ws.send({
|
||||
type: 'token-budget',
|
||||
data: tokenBudget,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
const tokenBudgetData = extractTokenBudget(message);
|
||||
if (tokenBudgetData) {
|
||||
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,13 +680,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send completion event
|
||||
console.log('Streaming complete, sending claude-complete event');
|
||||
ws.send({
|
||||
type: 'claude-complete',
|
||||
sessionId: capturedSessionId,
|
||||
exitCode: 0,
|
||||
isNewSession: !sessionId && !!command
|
||||
});
|
||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
|
||||
notifyRunStopped({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'claude',
|
||||
@@ -710,7 +688,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
sessionName: sessionSummary,
|
||||
stopReason: 'completed'
|
||||
});
|
||||
console.log('claude-complete event sent');
|
||||
// Complete
|
||||
|
||||
} catch (error) {
|
||||
console.error('SDK query error:', error);
|
||||
@@ -724,11 +702,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send error to WebSocket
|
||||
ws.send({
|
||||
type: 'claude-error',
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'claude',
|
||||
|
||||
Reference in New Issue
Block a user