import type { MessageContent, TextContentBlock } from './types'; /** * Utility functions for formatting and displaying data */ /** * Safely converts unknown values to a formatted string for display */ export function formatValue(value: unknown): string { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); try { return JSON.stringify(value, null, 2); } catch { return String(value); } } /** * Formats JSON with proper indentation and returns a formatted string * Set maxLength to 0 or Infinity for no truncation */ export function formatJSON(obj: unknown, maxLength: number = 50000): string { try { const jsonString = JSON.stringify(obj, null, 2); if (maxLength > 0 && maxLength < Infinity && jsonString.length > maxLength) { return jsonString.substring(0, maxLength) + '\n... (truncated - ' + jsonString.length.toLocaleString() + ' total chars)'; } return jsonString; } catch { return String(obj); } } /** * Formats JSON without truncation */ export function formatJSONFull(obj: unknown): string { try { return JSON.stringify(obj, null, 2); } catch { return String(obj); } } /** * Escapes HTML characters to prevent XSS. */ export function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Formats large text with proper line breaks and structure. * Supports markdown-like syntax: headings, bold, italic, inline code, * fenced code blocks, bullet/numbered lists, horizontal rules, and URLs. */ export function formatLargeText(text: string): string { if (!text) return ''; const codeBlocks: string[] = []; const withPlaceholders = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, _lang, code) => { const idx = codeBlocks.length; codeBlocks.push( `
${escapeHtml(code.replace(/\n$/, ''))}
` ); return `\x00CODEBLOCK_${idx}\x00`; }); const escaped = escapeHtml(withPlaceholders); const lines = escaped.split('\n'); const outputLines: string[] = []; let inList = false; let listType: 'ul' | 'ol' = 'ul'; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const cbMatch = line.match(/\x00CODEBLOCK_(\d+)\x00/); if (cbMatch) { if (inList) { outputLines.push(listType === 'ul' ? '' : ''); inList = false; } outputLines.push(codeBlocks[parseInt(cbMatch[1], 10)]); continue; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { if (inList) { outputLines.push(listType === 'ul' ? '' : ''); inList = false; } outputLines.push('
'); continue; } const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { if (inList) { outputLines.push(listType === 'ul' ? '' : ''); inList = false; } const level = headingMatch[1].length; const headingText = headingMatch[2]; const sizes: Record = { 1: 'text-xl font-bold text-gray-900 mt-5 mb-2', 2: 'text-lg font-bold text-gray-900 mt-4 mb-2', 3: 'text-base font-semibold text-gray-800 mt-3 mb-1', 4: 'text-sm font-semibold text-gray-800 mt-2 mb-1', 5: 'text-sm font-medium text-gray-700 mt-2 mb-1', 6: 'text-xs font-medium text-gray-700 mt-2 mb-1', }; outputLines.push(`
${applyInlineFormatting(headingText)}
`); continue; } const bulletMatch = line.match(/^(\s*)[-*+]\s+(.+)$/); if (bulletMatch) { if (!inList || listType !== 'ul') { if (inList) outputLines.push(listType === 'ul' ? '' : ''); outputLines.push('' : ''); outputLines.push('
    '); inList = true; listType = 'ol'; } outputLines.push(`
  1. ${applyInlineFormatting(numMatch[2])}
  2. `); continue; } if (inList && line.trim() !== '') { outputLines.push(listType === 'ul' ? '' : '
'); inList = false; } if (line.trim() === '') { outputLines.push('
'); continue; } outputLines.push(`
${applyInlineFormatting(line)}
`); } if (inList) outputLines.push(listType === 'ul' ? '' : ''); return outputLines.join('\n'); } function applyInlineFormatting(text: string): string { return text .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\bhttps?:\/\/[^\s<&]+/g, (url) => { return `${url}`; }); } export function isComplexObject(value: unknown): boolean { return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0; } export function truncateText(text: string, maxLength: number = 2000): string { if (text.length <= maxLength) return text; return text.substring(0, maxLength) + '... (' + (text.length - maxLength).toLocaleString() + ' more chars)'; } export function formatTimestamp(timestamp: string | Date): string { try { const date = new Date(timestamp); const now = new Date(); const diff = now.getTime() - date.getTime(); if (diff < 60000) return 'Just now'; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true }); } catch { return String(timestamp); } } /** * Format a duration in milliseconds to a human-readable short string (e.g. "3s", "5m", "2h") */ export function formatDuration(milliseconds: number): string { if (milliseconds < 60000) return `${Math.round(milliseconds / 1000)}s`; if (milliseconds < 3600000) return `${Math.round(milliseconds / 60000)}m`; return `${Math.round(milliseconds / 3600000)}h`; } /** * Format a timestamp string to a short time (e.g. "02:30 PM") */ export function formatTime(timestamp: string): string { try { return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true }); } catch { return timestamp; } } /** * Format a timestamp to date string using toLocaleDateString() */ export function formatDate(timestamp: string | Date): string { try { return new Date(timestamp).toLocaleDateString(); } catch { return String(timestamp); } } /** * Format a timestamp to time string using toLocaleTimeString() */ export function formatTimeOfDay(timestamp: string | Date): string { try { return new Date(timestamp).toLocaleTimeString(); } catch { return String(timestamp); } } export function formatFileSize(bytes: number): string { const sizes = ['B', 'KB', 'MB', 'GB']; if (bytes === 0) return '0 B'; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; } export interface XmlSegment { type: 'text' | 'xml'; content: string; tag?: string; innerContent?: string; } export function parseXmlBlocks(text: string): XmlSegment[] { if (!text) return []; const result: XmlSegment[] = []; const tagPattern = /<([a-z][a-z0-9_-]*(?:\s[^>]*)?)>/gi; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = tagPattern.exec(text)) !== null) { const fullOpenTag = match[0]; const tagContent = match[1]; const tagName = tagContent.split(/\s/)[0]; const openPos = match.index; const closeTag = ``; let depth = 1; let searchPos = openPos + fullOpenTag.length; while (depth > 0 && searchPos < text.length) { const nextOpen = text.indexOf(`<${tagName}`, searchPos); const nextClose = text.indexOf(closeTag, searchPos); if (nextClose === -1) break; if (nextOpen !== -1 && nextOpen < nextClose) { const charAfterName = text[nextOpen + tagName.length + 1]; if (charAfterName === '>' || charAfterName === ' ' || charAfterName === '\n') { depth++; } searchPos = nextOpen + tagName.length + 2; } else { depth--; if (depth === 0) { const innerStart = openPos + fullOpenTag.length; const innerEnd = nextClose; const blockEnd = nextClose + closeTag.length; if (openPos > lastIndex) { const preceding = text.substring(lastIndex, openPos).trim(); if (preceding) result.push({ type: 'text', content: preceding }); } result.push({ type: 'xml', content: text.substring(openPos, blockEnd), tag: tagName, innerContent: text.substring(innerStart, innerEnd) }); lastIndex = blockEnd; tagPattern.lastIndex = blockEnd; } else { searchPos = nextClose + closeTag.length; } } } if (depth > 0) { tagPattern.lastIndex = openPos + fullOpenTag.length; } } if (lastIndex < text.length) { const remaining = text.substring(lastIndex).trim(); if (remaining) result.push({ type: 'text', content: remaining }); } return result; } export function hasCustomXmlBlocks(text: string): boolean { return /<[a-z][a-z0-9_-]*(?:\s[^>]*)?>[\s\S]*?<\/[a-z][a-z0-9_-]*>/i.test(text); } export function getXmlTagStyle(tag: string): { bg: string; border: string; headerBg: string; text: string; icon: string } { if (/^(system-reminder|thinking_mode|reasoning_effort|antml_thinking_mode|fast_mode_info|claude[A-Z_-])/.test(tag)) { return { bg: 'bg-amber-50', border: 'border-amber-200', headerBg: 'bg-amber-100', text: 'text-amber-800', icon: 'settings' }; } if (/^(functions?|function_calls?|antml_function|antml_invoke|antml_parameter|tool)/.test(tag)) { return { bg: 'bg-emerald-50', border: 'border-emerald-200', headerBg: 'bg-emerald-100', text: 'text-emerald-800', icon: 'wrench' }; } if (/^(local-command|command-|user-prompt)/.test(tag)) { return { bg: 'bg-blue-50', border: 'border-blue-200', headerBg: 'bg-blue-100', text: 'text-blue-800', icon: 'terminal' }; } if (/^(types?|examples?|skills?|context|references?)/.test(tag)) { return { bg: 'bg-purple-50', border: 'border-purple-200', headerBg: 'bg-purple-100', text: 'text-purple-800', icon: 'database' }; } return { bg: 'bg-gray-50', border: 'border-gray-200', headerBg: 'bg-gray-100', text: 'text-gray-700', icon: 'code' }; } function isTextContentBlock(value: unknown): value is TextContentBlock { return !!value && typeof value === 'object' && 'type' in value && value.type === 'text' && 'text' in value && typeof value.text === 'string'; } export function createContentPreview(content: MessageContent | unknown, maxLength: number = 100): string { if (typeof content === 'string') { return content.length > maxLength ? content.substring(0, maxLength) + '...' : content; } if (Array.isArray(content)) { const textContent = content.find((item) => isTextContentBlock(item))?.text || ''; if (textContent) { return textContent.length > maxLength ? textContent.substring(0, maxLength) + '...' : textContent; } return `${content.length} content blocks`; } if (content && typeof content === 'object') { if ('text' in content && typeof content.text === 'string') { return content.text.length > maxLength ? content.text.substring(0, maxLength) + '...' : content.text; } return 'Complex content'; } return 'No content'; }