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:
simosmik
2026-03-19 00:03:07 +00:00
parent 612390db53
commit 7b3bc54d1b
26 changed files with 2664 additions and 2549 deletions

View File

@@ -4,7 +4,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
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';
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) => {
try {
const { sessionId } = req.params;

View File

@@ -1,39 +1,9 @@
import express from 'express';
import sessionManager from '../sessionManager.js';
import { sessionNamesDb } from '../database/db.js';
import { getGeminiCliSessionMessages } from '../projects.js';
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) => {
try {
const { sessionId } = req.params;

61
server/routes/messages.js Normal file
View 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;