mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-30 08:15:31 +08: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:
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user