mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-16 10:31:32 +00:00
feat: unified message architecture with provider adapters and session store (#558)
- 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:
@@ -52,8 +52,9 @@ interface UseChatComposerStateArgs {
|
||||
onShowSettings?: () => void;
|
||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||
scrollToBottom: () => void;
|
||||
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setSessionMessages?: Dispatch<SetStateAction<any[]>>;
|
||||
addMessage: (msg: ChatMessage) => void;
|
||||
clearMessages: () => void;
|
||||
rewindMessages: (count: number) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
@@ -123,8 +124,9 @@ export function useChatComposerState({
|
||||
onShowSettings,
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
setChatMessages,
|
||||
setSessionMessages,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -155,69 +157,50 @@ export function useChatComposerState({
|
||||
const { action, data } = result;
|
||||
switch (action) {
|
||||
case 'clear':
|
||||
setChatMessages([]);
|
||||
setSessionMessages?.([]);
|
||||
clearMessages();
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'model':
|
||||
setChatMessages((previous) => [
|
||||
...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(', ')}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
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;
|
||||
|
||||
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}`;
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{ type: 'assistant', content: costMessage, timestamp: Date.now() },
|
||||
]);
|
||||
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
|
||||
break;
|
||||
}
|
||||
|
||||
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}`;
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{ type: 'assistant', content: statusMessage, timestamp: Date.now() },
|
||||
]);
|
||||
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'memory':
|
||||
if (data.error) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⚠️ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `Warning: ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `${data.message}\n\nPath: \`${data.path}\``,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (data.exists && onFileOpen) {
|
||||
onFileOpen(data.path);
|
||||
}
|
||||
@@ -230,24 +213,18 @@ export function useChatComposerState({
|
||||
|
||||
case 'rewind':
|
||||
if (data.error) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⚠️ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `Warning: ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
setChatMessages((previous) => previous.slice(0, -data.steps * 2));
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⏪ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
rewindMessages(data.steps * 2);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `Rewound ${data.steps} step(s). ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -255,7 +232,7 @@ export function useChatComposerState({
|
||||
console.warn('Unknown built-in command action:', action);
|
||||
}
|
||||
},
|
||||
[onFileOpen, onShowSettings, setChatMessages, setSessionMessages],
|
||||
[onFileOpen, onShowSettings, addMessage, clearMessages, rewindMessages],
|
||||
);
|
||||
|
||||
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?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: '❌ Command execution cancelled',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: 'Command execution cancelled',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -288,7 +262,7 @@ export function useChatComposerState({
|
||||
handleSubmitRef.current(createFakeSubmitEvent());
|
||||
}
|
||||
}, 0);
|
||||
}, [setChatMessages]);
|
||||
}, [addMessage]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (command: SlashCommand, rawInput?: string) => {
|
||||
@@ -346,14 +320,11 @@ export function useChatComposerState({
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error executing command:', error);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `Error executing command: ${message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: `Error executing command: ${message}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -367,7 +338,7 @@ export function useChatComposerState({
|
||||
input,
|
||||
provider,
|
||||
selectedProject,
|
||||
setChatMessages,
|
||||
addMessage,
|
||||
tokenBudget,
|
||||
],
|
||||
);
|
||||
@@ -547,18 +518,19 @@ export function useChatComposerState({
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Image upload failed:', error);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: `Failed to upload images: ${message}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
addMessage({
|
||||
type: 'error',
|
||||
content: `Failed to upload images: ${message}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveSessionId =
|
||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
type: 'user',
|
||||
content: currentInput,
|
||||
@@ -566,7 +538,7 @@ export function useChatComposerState({
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setChatMessages((previous) => [...previous, userMessage]);
|
||||
addMessage(userMessage);
|
||||
setIsLoading(true); // Processing banner starts
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
@@ -578,10 +550,6 @@ export function useChatComposerState({
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
const effectiveSessionId =
|
||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
||||
@@ -723,7 +691,7 @@ export function useChatComposerState({
|
||||
selectedProject,
|
||||
sendMessage,
|
||||
setCanAbortSession,
|
||||
setChatMessages,
|
||||
addMessage,
|
||||
setClaudeStatus,
|
||||
setIsLoading,
|
||||
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
Reference in New Issue
Block a user