mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-08 21:48:20 +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:
@@ -5,32 +5,12 @@ export const CLAUDE_SETTINGS_KEY = 'claude-settings';
|
||||
export const safeLocalStorage = {
|
||||
setItem: (key: string, value: string) => {
|
||||
try {
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.length > 50) {
|
||||
const truncated = parsed.slice(-50);
|
||||
value = JSON.stringify(truncated);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Could not parse chat messages for truncation:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'QuotaExceededError') {
|
||||
console.warn('localStorage quota exceeded, clearing old data');
|
||||
|
||||
const keys = Object.keys(localStorage);
|
||||
const chatKeys = keys.filter((k) => k.startsWith('chat_messages_')).sort();
|
||||
|
||||
if (chatKeys.length > 3) {
|
||||
chatKeys.slice(0, chatKeys.length - 3).forEach((k) => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
}
|
||||
|
||||
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
|
||||
draftKeys.forEach((k) => {
|
||||
localStorage.removeItem(k);
|
||||
@@ -40,17 +20,6 @@ export const safeLocalStorage = {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (retryError) {
|
||||
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.length > 10) {
|
||||
const minimal = parsed.slice(-10);
|
||||
localStorage.setItem(key, JSON.stringify(minimal));
|
||||
}
|
||||
} catch (finalError) {
|
||||
console.error('Final save attempt failed:', finalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('localStorage error:', error);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { ChatMessage } from '../types/types';
|
||||
import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'added' | 'removed';
|
||||
content: string;
|
||||
@@ -9,80 +6,6 @@ export interface DiffLine {
|
||||
|
||||
export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];
|
||||
|
||||
type CursorBlob = {
|
||||
id?: string;
|
||||
sequence?: number;
|
||||
rowid?: number;
|
||||
content?: any;
|
||||
};
|
||||
|
||||
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
|
||||
|
||||
const normalizeToolInput = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
|
||||
/<user_info>[\s\S]*?<\/user_info>/gi,
|
||||
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
|
||||
/<available_skills>[\s\S]*?<\/available_skills>/gi,
|
||||
/<environment_context>[\s\S]*?<\/environment_context>/gi,
|
||||
/<environment_info>[\s\S]*?<\/environment_info>/gi,
|
||||
];
|
||||
|
||||
const extractCursorUserQuery = (rawText: string): string => {
|
||||
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
|
||||
if (userQueryMatches.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return userQueryMatches
|
||||
.map((match) => (match[1] || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const sanitizeCursorUserMessageText = (rawText: string): string => {
|
||||
const decodedText = decodeHtmlEntities(rawText || '').trim();
|
||||
if (!decodedText) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
|
||||
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
|
||||
const extractedUserQuery = extractCursorUserQuery(decodedText);
|
||||
if (extractedUserQuery) {
|
||||
return extractedUserQuery;
|
||||
}
|
||||
|
||||
let sanitizedText = decodedText;
|
||||
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
|
||||
sanitizedText = sanitizedText.replace(pattern, '');
|
||||
});
|
||||
|
||||
return sanitizedText.trim();
|
||||
};
|
||||
|
||||
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
return filePath.startsWith('/') ? filePath : `${projectPath}/${filePath}`;
|
||||
};
|
||||
|
||||
export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {
|
||||
const oldLines = oldStr.split('\n');
|
||||
const newLines = newStr.split('\n');
|
||||
@@ -162,434 +85,3 @@ export const createCachedDiffCalculator = (): DiffCalculator => {
|
||||
return calculated;
|
||||
};
|
||||
};
|
||||
|
||||
export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: string): ChatMessage[] => {
|
||||
const converted: ChatMessage[] = [];
|
||||
const toolUseMap: Record<string, ChatMessage> = {};
|
||||
|
||||
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx += 1) {
|
||||
const blob = blobs[blobIdx];
|
||||
const content = blob.content;
|
||||
let text = '';
|
||||
let role: ChatMessage['type'] = 'assistant';
|
||||
let reasoningText: string | null = null;
|
||||
|
||||
try {
|
||||
if (content?.role && content?.content) {
|
||||
if (content.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'tool') {
|
||||
const toolItems = asArray<any>(content.content);
|
||||
for (const item of toolItems) {
|
||||
if (item?.type !== 'tool-result') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolName = item.toolName === 'ApplyPatch' ? 'Edit' : item.toolName || 'Unknown Tool';
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
const result = item.result || '';
|
||||
|
||||
if (toolCallId && toolUseMap[toolCallId]) {
|
||||
toolUseMap[toolCallId].toolResult = {
|
||||
content: result,
|
||||
isError: false,
|
||||
};
|
||||
} else {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
isToolUse: true,
|
||||
toolName,
|
||||
toolId: toolCallId,
|
||||
toolInput: normalizeToolInput(null),
|
||||
toolResult: {
|
||||
content: result,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
role = content.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content.content)) {
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const part of content.content) {
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
textParts.push(decodeHtmlEntities(part.text));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part?.type === 'reasoning' && part?.text) {
|
||||
reasoningText = decodeHtmlEntities(part.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
if (textParts.length > 0 || reasoningText) {
|
||||
converted.push({
|
||||
type: role,
|
||||
content: textParts.join('\n'),
|
||||
reasoning: reasoningText ?? undefined,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
});
|
||||
textParts.length = 0;
|
||||
reasoningText = null;
|
||||
}
|
||||
|
||||
const toolNameRaw = part.toolName || part.name || 'Unknown Tool';
|
||||
const toolName = toolNameRaw === 'ApplyPatch' ? 'Edit' : toolNameRaw;
|
||||
const toolId = part.toolCallId || part.id || `tool_${blobIdx}`;
|
||||
let toolInput = part.args || part.input;
|
||||
|
||||
if (toolName === 'Edit' && part.args) {
|
||||
if (part.args.patch) {
|
||||
const patchLines = String(part.args.patch).split('\n');
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
let inPatch = false;
|
||||
|
||||
patchLines.forEach((line) => {
|
||||
if (line.startsWith('@@')) {
|
||||
inPatch = true;
|
||||
return;
|
||||
}
|
||||
if (!inPatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
oldLines.push(line.slice(1));
|
||||
} else if (line.startsWith('+')) {
|
||||
newLines.push(line.slice(1));
|
||||
} else if (line.startsWith(' ')) {
|
||||
oldLines.push(line.slice(1));
|
||||
newLines.push(line.slice(1));
|
||||
}
|
||||
});
|
||||
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, part.args.file_path),
|
||||
old_string: oldLines.join('\n') || part.args.patch,
|
||||
new_string: newLines.join('\n') || part.args.patch,
|
||||
};
|
||||
} else {
|
||||
toolInput = part.args;
|
||||
}
|
||||
} else if (toolName === 'Read' && part.args) {
|
||||
const filePath = part.args.path || part.args.file_path;
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, filePath),
|
||||
};
|
||||
} else if (toolName === 'Write' && part.args) {
|
||||
const filePath = part.args.path || part.args.file_path;
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, filePath),
|
||||
content: part.args.contents || part.args.content,
|
||||
};
|
||||
}
|
||||
|
||||
const toolMessage: ChatMessage = {
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
isToolUse: true,
|
||||
toolName,
|
||||
toolId,
|
||||
toolInput: normalizeToolInput(toolInput),
|
||||
toolResult: null,
|
||||
};
|
||||
converted.push(toolMessage);
|
||||
toolUseMap[toolId] = toolMessage;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof part === 'string') {
|
||||
textParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (textParts.length > 0) {
|
||||
text = textParts.join('\n');
|
||||
if (reasoningText && !text) {
|
||||
converted.push({
|
||||
type: role,
|
||||
content: '',
|
||||
reasoning: reasoningText,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
});
|
||||
text = '';
|
||||
}
|
||||
} else {
|
||||
text = '';
|
||||
}
|
||||
} else if (typeof content.content === 'string') {
|
||||
text = content.content;
|
||||
}
|
||||
} else if (content?.message?.role && content?.message?.content) {
|
||||
if (content.message.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map((part: any) => (typeof part === 'string' ? part : part?.text || ''))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing blob content:', error);
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
text = sanitizeCursorUserMessageText(text);
|
||||
}
|
||||
|
||||
if (text && text.trim()) {
|
||||
const message: ChatMessage = {
|
||||
type: role,
|
||||
content: text,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
};
|
||||
if (reasoningText) {
|
||||
message.reasoning = reasoningText;
|
||||
}
|
||||
converted.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
converted.sort((messageA, messageB) => {
|
||||
if (messageA.sequence !== undefined && messageB.sequence !== undefined) {
|
||||
return Number(messageA.sequence) - Number(messageB.sequence);
|
||||
}
|
||||
if (messageA.rowid !== undefined && messageB.rowid !== undefined) {
|
||||
return Number(messageA.rowid) - Number(messageB.rowid);
|
||||
}
|
||||
return new Date(messageA.timestamp).getTime() - new Date(messageB.timestamp).getTime();
|
||||
});
|
||||
|
||||
return converted;
|
||||
};
|
||||
|
||||
export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
const converted: ChatMessage[] = [];
|
||||
const toolResults = new Map<
|
||||
string,
|
||||
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] }
|
||||
>();
|
||||
|
||||
rawMessages.forEach((message) => {
|
||||
if (message.message?.role === 'user' && Array.isArray(message.message?.content)) {
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type !== 'tool_result') {
|
||||
return;
|
||||
}
|
||||
toolResults.set(part.tool_use_id, {
|
||||
content: part.content,
|
||||
isError: Boolean(part.is_error),
|
||||
timestamp: new Date(message.timestamp || Date.now()),
|
||||
toolUseResult: message.toolUseResult || null,
|
||||
subagentTools: message.subagentTools,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rawMessages.forEach((message) => {
|
||||
if (message.message?.role === 'user' && message.message?.content) {
|
||||
let content = '';
|
||||
if (Array.isArray(message.message.content)) {
|
||||
const textParts: string[] = [];
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type === 'text') {
|
||||
textParts.push(decodeHtmlEntities(part.text));
|
||||
}
|
||||
});
|
||||
content = textParts.join('\n');
|
||||
} else if (typeof message.message.content === 'string') {
|
||||
content = decodeHtmlEntities(message.message.content);
|
||||
} else {
|
||||
content = decodeHtmlEntities(String(message.message.content));
|
||||
}
|
||||
|
||||
const shouldSkip =
|
||||
!content ||
|
||||
content.startsWith('<command-name>') ||
|
||||
content.startsWith('<command-message>') ||
|
||||
content.startsWith('<command-args>') ||
|
||||
content.startsWith('<local-command-stdout>') ||
|
||||
content.startsWith('<system-reminder>') ||
|
||||
content.startsWith('Caveat:') ||
|
||||
content.startsWith('This session is being continued from a previous') ||
|
||||
content.startsWith('[Request interrupted');
|
||||
|
||||
if (!shouldSkip) {
|
||||
// Parse <task-notification> blocks into compact system messages
|
||||
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) {
|
||||
const status = taskNotifMatch[1]?.trim() || 'completed';
|
||||
const summary = taskNotifMatch[2]?.trim() || 'Background task finished';
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: summary,
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isTaskNotification: true,
|
||||
taskStatus: status,
|
||||
});
|
||||
} else {
|
||||
converted.push({
|
||||
type: 'user',
|
||||
content: unescapeWithMathProtection(content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'thinking' && message.message?.content) {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: unescapeWithMathProtection(message.message.content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isThinking: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'tool_use' && message.toolName) {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isToolUse: true,
|
||||
toolName: message.toolName,
|
||||
toolInput: normalizeToolInput(message.toolInput),
|
||||
toolCallId: message.toolCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'tool_result') {
|
||||
for (let index = converted.length - 1; index >= 0; index -= 1) {
|
||||
const convertedMessage = converted[index];
|
||||
if (!convertedMessage.isToolUse || convertedMessage.toolResult) {
|
||||
continue;
|
||||
}
|
||||
if (!message.toolCallId || convertedMessage.toolCallId === message.toolCallId) {
|
||||
convertedMessage.toolResult = {
|
||||
content: message.output || '',
|
||||
isError: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.message?.role === 'assistant' && message.message?.content) {
|
||||
if (Array.isArray(message.message.content)) {
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type === 'text') {
|
||||
let text = part.text;
|
||||
if (typeof text === 'string') {
|
||||
text = unescapeWithMathProtection(text);
|
||||
}
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: text,
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'tool_use') {
|
||||
const toolResult = toolResults.get(part.id);
|
||||
const isSubagentContainer = part.name === 'Task';
|
||||
|
||||
// Build child tools from server-provided subagentTools data
|
||||
const childTools: import('../types/types').SubagentChildTool[] = [];
|
||||
if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) {
|
||||
for (const tool of toolResult.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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isToolUse: true,
|
||||
toolName: part.name,
|
||||
toolInput: normalizeToolInput(part.input),
|
||||
toolId: part.id,
|
||||
toolResult: toolResult
|
||||
? {
|
||||
content:
|
||||
typeof toolResult.content === 'string'
|
||||
? toolResult.content
|
||||
: JSON.stringify(toolResult.content),
|
||||
isError: toolResult.isError,
|
||||
toolUseResult: toolResult.toolUseResult,
|
||||
}
|
||||
: null,
|
||||
toolError: toolResult?.isError || false,
|
||||
toolResultTimestamp: toolResult?.timestamp || new Date(),
|
||||
isSubagentContainer,
|
||||
subagentState: isSubagentContainer
|
||||
? {
|
||||
childTools,
|
||||
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
|
||||
isComplete: Boolean(toolResult),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message.message.content === 'string') {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: unescapeWithMathProtection(message.message.content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return converted;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user