mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-31 00:55:42 +08:00
refactor(chat): split monolithic chat interface into typed modules and hooks
Replace the legacy monolithic ChatInterface.jsx implementation with a modular TypeScript architecture centered around a small orchestration component (ChatInterface.tsx). Core architecture changes: - Remove src/components/ChatInterface.jsx and add src/components/ChatInterface.tsx as a thin coordinator that wires provider state, session state, realtime WebSocket handlers, and composer behavior via dedicated hooks. - Update src/components/MainContent.tsx to use typed ChatInterface directly (remove AnyChatInterface cast). State ownership and hook extraction: - Add src/hooks/chat/useChatProviderState.ts to centralize provider/model/permission-mode state, provider/session synchronization, cursor model bootstrap from backend config, and pending permission request scoping. - Add src/hooks/chat/useChatSessionState.ts to own chat/session lifecycle state: session loading, cursor/claude/codex history loading, pagination, scroll restoration, visible-window slicing, token budget loading, persisted chat hydration, and processing-state restoration. - Add src/hooks/chat/useChatRealtimeHandlers.ts to isolate WebSocket event processing for Claude/Cursor/Codex, including session filtering, streaming chunk buffering, session-created/pending-session transitions, permission request queueing/cancellation, completion/error handling, and session status updates. - Add src/hooks/chat/useChatComposerState.ts to own composer-local state and interactions: input/draft persistence, textarea sizing and keyboard behavior, slash command execution, file mentions, image attachment/drop/paste workflow, submit/abort flows, permission decision responses, and transcript insertion. UI modularization under src/components/chat: - Add view/ChatMessagesPane.tsx for message list rendering, loading/empty states, pagination affordances, and thinking indicator. - Add view/ChatComposer.tsx for composer shell layout and input area composition. - Add view/ChatInputControls.tsx for mode toggles, token display, command launcher, clear-input, and scroll-to-bottom controls. - Add view/PermissionRequestsBanner.tsx for explicit tool-permission review actions (allow once / allow & remember / deny). - Add view/ProviderSelectionEmptyState.tsx for provider and model selection UX plus task starter integration. - Add messages/MessageComponent.tsx and markdown/Markdown.tsx to isolate message rendering concerns, markdown/code rendering, and rich tool-output presentation. - Add input/ImageAttachment.tsx for attachment previews/removal/progress/error overlay rendering. Shared chat typing and utilities: - Add src/components/chat/types.ts with shared types for providers, permission mode, message/tool payloads, pending permission requests, and ChatInterfaceProps. - Add src/components/chat/utils/chatFormatting.ts for html decoding, code fence normalization, regex escaping, math-safe unescaping, and usage-limit text formatting. - Add src/components/chat/utils/chatPermissions.ts for permission rule derivation, suggestion generation, and grant flow. - Add src/components/chat/utils/chatStorage.ts for resilient localStorage access, quota handling, and normalized Claude settings retrieval. - Add src/components/chat/utils/messageTransforms.ts for session message normalization (Claude/Codex/Cursor) and cached diff computation utilities. Command/file input ergonomics: - Add src/hooks/chat/useSlashCommands.ts for slash command fetching, usage-based ranking, fuzzy filtering, keyboard navigation, and command history persistence. - Add src/hooks/chat/useFileMentions.tsx for project file flattening, @mention suggestions, mention highlighting, and keyboard/file insertion behavior. TypeScript support additions: - Add src/types/react-syntax-highlighter.d.ts module declarations to type-check markdown code highlighting imports. Behavioral intent: - Preserve existing chat behavior and provider flows while improving readability, separation of concerns, and future refactorability. - Move state closer to the components/hooks that own it, reducing cross-cutting concerns in the top-level chat component.
This commit is contained in:
86
src/components/chat/utils/chatFormatting.ts
Normal file
86
src/components/chat/utils/chatFormatting.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export function decodeHtmlEntities(text: string) {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
export function normalizeInlineCodeFences(text: string) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
try {
|
||||
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export function unescapeWithMathProtection(text: string) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
|
||||
const mathBlocks: string[] = [];
|
||||
const placeholderPrefix = '__MATH_BLOCK_';
|
||||
const placeholderSuffix = '__';
|
||||
|
||||
let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => {
|
||||
const index = mathBlocks.length;
|
||||
mathBlocks.push(match);
|
||||
return `${placeholderPrefix}${index}${placeholderSuffix}`;
|
||||
});
|
||||
|
||||
processedText = processedText.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
|
||||
|
||||
processedText = processedText.replace(
|
||||
new RegExp(`${placeholderPrefix}(\\d+)${placeholderSuffix}`, 'g'),
|
||||
(match, index) => {
|
||||
return mathBlocks[parseInt(index, 10)];
|
||||
},
|
||||
);
|
||||
|
||||
return processedText;
|
||||
}
|
||||
|
||||
export function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function formatUsageLimitText(text: string) {
|
||||
try {
|
||||
if (typeof text !== 'string') return text;
|
||||
return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => {
|
||||
let timestampMs = parseInt(ts, 10);
|
||||
if (!Number.isFinite(timestampMs)) return match;
|
||||
if (timestampMs < 1e12) timestampMs *= 1000;
|
||||
const reset = new Date(timestampMs);
|
||||
|
||||
const timeStr = new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(reset);
|
||||
|
||||
const offsetMinutesLocal = -reset.getTimezoneOffset();
|
||||
const sign = offsetMinutesLocal >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(offsetMinutesLocal);
|
||||
const offH = Math.floor(abs / 60);
|
||||
const offM = abs % 60;
|
||||
const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`;
|
||||
const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
const cityRaw = tzId.split('/').pop() || '';
|
||||
const city = cityRaw
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
const tzHuman = city ? `${gmt} (${city})` : gmt;
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`;
|
||||
|
||||
return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`;
|
||||
});
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
64
src/components/chat/utils/chatPermissions.ts
Normal file
64
src/components/chat/utils/chatPermissions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { safeJsonParse } from '../../../lib/utils.js';
|
||||
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult } from '../types';
|
||||
import { CLAUDE_SETTINGS_KEY, getClaudeSettings, safeLocalStorage } from './chatStorage';
|
||||
|
||||
export function buildClaudeToolPermissionEntry(toolName?: string, toolInput?: unknown) {
|
||||
if (!toolName) return null;
|
||||
if (toolName !== 'Bash') return toolName;
|
||||
|
||||
const parsed = safeJsonParse(toolInput);
|
||||
const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';
|
||||
if (!command) return toolName;
|
||||
|
||||
const tokens = command.split(/\s+/);
|
||||
if (tokens.length === 0) return toolName;
|
||||
|
||||
if (tokens[0] === 'git' && tokens[1]) {
|
||||
return `Bash(${tokens[0]} ${tokens[1]}:*)`;
|
||||
}
|
||||
return `Bash(${tokens[0]}:*)`;
|
||||
}
|
||||
|
||||
export function formatToolInputForDisplay(input: unknown) {
|
||||
if (input === undefined || input === null) return '';
|
||||
if (typeof input === 'string') return input;
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudePermissionSuggestion(
|
||||
message: ChatMessage | null | undefined,
|
||||
provider: string,
|
||||
): ClaudePermissionSuggestion | null {
|
||||
if (provider !== 'claude') return null;
|
||||
if (!message?.toolResult?.isError) return null;
|
||||
|
||||
const toolName = message?.toolName;
|
||||
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
|
||||
if (!entry) return null;
|
||||
|
||||
const settings = getClaudeSettings();
|
||||
const isAllowed = settings.allowedTools.includes(entry);
|
||||
return { toolName: toolName || 'UnknownTool', entry, isAllowed };
|
||||
}
|
||||
|
||||
export function grantClaudeToolPermission(entry: string | null): PermissionGrantResult {
|
||||
if (!entry) return { success: false };
|
||||
|
||||
const settings = getClaudeSettings();
|
||||
const alreadyAllowed = settings.allowedTools.includes(entry);
|
||||
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
|
||||
const nextDisallowed = settings.disallowedTools.filter((tool) => tool !== entry);
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
allowedTools: nextAllowed,
|
||||
disallowedTools: nextDisallowed,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
|
||||
return { success: true, alreadyAllowed, updatedSettings };
|
||||
}
|
||||
105
src/components/chat/utils/chatStorage.ts
Normal file
105
src/components/chat/utils/chatStorage.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ClaudeSettings } from '../types';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
getItem: (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage getItem error:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage removeItem error:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function getClaudeSettings(): ClaudeSettings {
|
||||
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
projectSortOrder: 'name',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...parsed,
|
||||
allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],
|
||||
disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],
|
||||
skipPermissions: Boolean(parsed.skipPermissions),
|
||||
projectSortOrder: parsed.projectSortOrder || 'name',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
projectSortOrder: 'name',
|
||||
};
|
||||
}
|
||||
}
|
||||
473
src/components/chat/utils/messageTransforms.ts
Normal file
473
src/components/chat/utils/messageTransforms.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import type { ChatMessage } from '../types';
|
||||
import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'added' | 'removed';
|
||||
content: string;
|
||||
lineNum: number;
|
||||
}
|
||||
|
||||
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 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');
|
||||
|
||||
const diffLines: DiffLine[] = [];
|
||||
let oldIndex = 0;
|
||||
let newIndex = 0;
|
||||
|
||||
while (oldIndex < oldLines.length || newIndex < newLines.length) {
|
||||
const oldLine = oldLines[oldIndex];
|
||||
const newLine = newLines[newIndex];
|
||||
|
||||
if (oldIndex >= oldLines.length) {
|
||||
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
||||
newIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newIndex >= newLines.length) {
|
||||
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
||||
oldIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (oldLine === newLine) {
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
||||
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
export const createCachedDiffCalculator = (): DiffCalculator => {
|
||||
const cache = new Map<string, DiffLine[]>();
|
||||
|
||||
return (oldStr: string, newStr: string) => {
|
||||
const key = `${oldStr.length}-${newStr.length}-${oldStr.slice(0, 50)}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const calculated = calculateDiff(oldStr, newStr);
|
||||
cache.set(key, calculated);
|
||||
if (cache.size > 100) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey) {
|
||||
cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
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: 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: toolInput ? JSON.stringify(toolInput) : null,
|
||||
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 (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 }
|
||||
>();
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
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: 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);
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isToolUse: true,
|
||||
toolName: part.name,
|
||||
toolInput: JSON.stringify(part.input),
|
||||
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(),
|
||||
});
|
||||
}
|
||||
});
|
||||
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