mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-29 09:47:25 +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:
@@ -44,7 +44,7 @@ import pty from 'node-pty';
|
||||
import fetch from 'node-fetch';
|
||||
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 { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.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 geminiRoutes from './routes/gemini.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 { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
@@ -396,6 +398,9 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
|
||||
// Plugins API Routes (protected)
|
||||
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||
|
||||
// Unified session messages route (protected)
|
||||
app.use('/api/sessions', authenticateToken, messagesRoutes);
|
||||
|
||||
// Agent API Routes (uses API key authentication)
|
||||
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
|
||||
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -958,7 +938,6 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
||||
}
|
||||
|
||||
const files = await getFileTree(actualPath, 10, 0, true);
|
||||
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
||||
res.json(files);
|
||||
} catch (error) {
|
||||
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
|
||||
*
|
||||
* Provider files use `createNormalizedMessage()` from `providers/types.js` and
|
||||
* adapter `normalizeMessage()` to produce unified NormalizedMessage events.
|
||||
* The writer simply serialises and sends.
|
||||
*/
|
||||
class WebSocketWriter {
|
||||
constructor(ws, userId = null) {
|
||||
@@ -1474,7 +1457,6 @@ class WebSocketWriter {
|
||||
|
||||
send(data) {
|
||||
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
||||
// Providers send raw objects, we stringify for WebSocket
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
@@ -1555,12 +1537,7 @@ function handleChatConnection(ws, request) {
|
||||
success = await abortClaudeSDKSession(data.sessionId);
|
||||
}
|
||||
|
||||
writer.send({
|
||||
type: 'session-aborted',
|
||||
sessionId: data.sessionId,
|
||||
provider,
|
||||
success
|
||||
});
|
||||
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
|
||||
} else if (data.type === 'claude-permission-response') {
|
||||
// Relay UI approval decisions back into the SDK control flow.
|
||||
// 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') {
|
||||
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
||||
const success = abortCursorSession(data.sessionId);
|
||||
writer.send({
|
||||
type: 'session-aborted',
|
||||
sessionId: data.sessionId,
|
||||
provider: 'cursor',
|
||||
success
|
||||
});
|
||||
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
|
||||
} else if (data.type === 'check-session-status') {
|
||||
// Check if a specific session is currently processing
|
||||
const provider = data.provider || 'claude';
|
||||
|
||||
Reference in New Issue
Block a user