/** * Formatting Utility Functions * @module utils/format * @version 1.1.0 */ /* eslint-env browser */ /** * Known Claude model name mappings * https://platform.claude.com/docs/en/about-claude/models/overview */ const MODEL_NAMES: Record = { // Claude 4.6 'claude-opus-4-6': 'Claude Opus 4.6', 'claude-sonnet-4-6': 'Claude Sonnet 4.6', // Claude 4.5 'claude-opus-4-5-20251101': 'Claude Opus 4.5', 'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5', 'claude-haiku-4-5-20251001': 'Claude Haiku 4.5', // Claude 4.0 'claude-sonnet-4-20250514': 'Claude Sonnet 4', 'claude-opus-4-20250514': 'Claude Opus 4', // Legacy 'claude-3-7-sonnet-20250219': 'Claude Sonnet 3.7', 'claude-3-haiku-20240307': 'Claude Haiku 3', // GPT / Codex 'gpt-5.3-codex': 'GPT-5.3 Codex', 'gpt-5.2-codex': 'GPT-5.2 Codex', 'gpt-5.1-codex-max': 'GPT-5.1 Codex Max', 'gpt-4.1': 'GPT-4.1', 'gpt-4o': 'GPT-4o', 'gpt-4o-mini': 'GPT-4o Mini', o1: 'o1', 'o1-mini': 'o1 Mini', 'o3-mini': 'o3 Mini', }; /** * Get human-friendly model name from model ID * @param {string} model - Model ID (e.g., 'claude-sonnet-4-20250514') * @returns {string} Human-friendly name (e.g., 'Claude 4 Sonnet') */ export function formatModelName(model: string | null | undefined): string { if (!model || model === 'default') { return 'Default'; } // Check known model mappings if (MODEL_NAMES[model]) { return MODEL_NAMES[model]; } // Try to extract friendly name from model string if (model.includes('opus')) { return 'Claude Opus'; } if (model.includes('sonnet')) { return 'Claude Sonnet'; } if (model.includes('haiku')) { return 'Claude Haiku'; } return model; } /** * Format message timestamp * @param {Date} date - Date object * @returns {string} Formatted time (HH:MM) */ export function formatMessageTime(date: Date): string { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } /** * Format checkpoint timestamp * @param {string|Date} timestamp - Timestamp * @returns {string} Formatted relative time */ export function formatCheckpointTime(timestamp: string | Date): string { const date = new Date(timestamp); const now = new Date(); const diff = now.getTime() - date.getTime(); if (diff < 3600000) { const mins = Math.floor(diff / 60000); return `${mins}m ago`; } if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return `${hours}h ago`; } return ( date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) ); } /** * Format relative time * @param {string|Date} timestamp - Timestamp * @returns {string} Relative time string */ export function formatRelativeTime(timestamp: string | number | Date | null | undefined): string { if (!timestamp) { return 'Never'; } const date = new Date(timestamp); if (Number.isNaN(date.getTime())) { return 'Never'; } const now = new Date(); const diff = now.getTime() - date.getTime(); if (diff < 60000) { return 'Just now'; } if (diff < 3600000) { const mins = Math.floor(diff / 60000); return `${mins}m ago`; } if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return `${hours}h ago`; } if (diff < 604800000) { const days = Math.floor(diff / 86400000); return `${days}d ago`; } return date.toLocaleDateString(); } /** * Truncate text with ellipsis * @param {string} text - Text to truncate * @param {number} maxLength - Maximum length * @returns {string} Truncated text */ export function truncateText(text: string | null | undefined, maxLength: number): string { if (!text) { return ''; } if (text.length <= maxLength) { return text; } return text.substring(0, maxLength) + '...'; } /** * Extract first meaningful line from text * @param {string} text - Text to extract from * @returns {string} First meaningful line */ export function extractFirstLine(text: string | null | undefined): string { if (!text) { return 'No summary'; } const lines = text.split('\n').filter((l) => l.trim() && !l.startsWith('**')); return lines[0] || text.substring(0, 100); } /** * Format assistant message with markdown support * @param {string} text - Text to format * @returns {string} Formatted HTML */ export function formatAssistantMessage(text: string | null | undefined): string { if (!text) { return ''; } // First escape HTML to prevent XSS let formatted = escapeHtmlForMarkdown(text); // Detect and wrap checkpoint/context sections in collapsible formatted = wrapCheckpointSections(formatted); // Code blocks with optional language (```js ... ```) formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => { const langClass = lang ? ` class="language-${lang}"` : ''; return `
${code.trim()}
`; }); // Inline code formatted = formatted.replace(/`([^`]+)`/g, '$1'); // Bold formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '$1'); // Italic (avoiding conflicts with bold) - Safari-compatible without lookbehind // Process after bold, match single asterisks not part of ** sequences formatted = formatted.replace(/([^*]|^)\*([^*]+)\*([^*]|$)/g, '$1$2$3'); // Helper: build safe media HTML from captured filename // Note: filename may contain HTML entities from prior escaping, so decode first const buildMediaHtml = (filename: string): string => { const decodedName = decodeHtmlEntities(filename); const safeName = encodeURIComponent(decodedName); const safeAlt = escapeHtmlForMarkdown(decodedName).replace(/"/g, '"'); const ext = decodedName.split('.').pop()?.toLowerCase() || ''; const imgExts = ['png', 'jpg', 'jpeg', 'gif', 'webp']; if (imgExts.includes(ext)) { return `
${safeAlt}Download ${safeAlt}
`; } return `Download ${safeAlt}`; }; // Markdown images: ![alt](media-path) — render as inline images // Note: exclude /api/media/download/ paths (they're already download links) formatted = formatted.replace( /!\[([^\]]*)\]\((?:~\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/home\/[^/]+\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/api\/media\/(?!download\/))([^)]+)\)/gi, (_match, _alt, filename) => buildMediaHtml(filename) ); // First: strip any wrappers around media paths (from markdown link handler) formatted = formatted.replace( /]*>[^<]*<\/a>/gi, (_match, filename) => buildMediaHtml(filename) ); // Then: handle bare media paths not already inside HTML tags // Safari-compatible: use capture group instead of lookbehind formatted = formatted.replace( /(^|[^"'])(?:~\/\.mama\/workspace\/media\/(?:outbound|inbound)\/|\/home\/[^/]+\/\.mama\/workspace\/media\/(?:outbound|inbound)\/)([^\s<"']+\.(png|jpe?g|gif|webp|svg|pdf|docx|doc|txt|csv|xlsx|xls|pptx|ppt|md|json|html|htm|xml|zip|gz))/gi, (_, prefix, filename) => prefix + buildMediaHtml(filename) ); // Headers (## and ###) formatted = formatted.replace( /^### (.+)$/gm, '

$1

' ); formatted = formatted.replace( /^## (.+)$/gm, '

$1

' ); // Bullet lists (- item) formatted = formatted.replace(/^- (.+)$/gm, '
  • • $1
  • '); // Quiz choices as buttons - patterns like **A)** text or A) text // Also handles blockquote prefix (> or >) // Match patterns: A) text, **A)** text, > A) text, etc. formatted = formatted.replace( /^(?:>\s*)?(?:)?([A-D])\)(?:<\/strong>)?\s*(.+)$/gim, (match, letter, text) => { const upperLetter = letter.toUpperCase(); return ``; } ); // Line breaks formatted = formatted.replace(/\n/g, '
    '); // Clean up multiple
    in lists formatted = formatted.replace(/<\/li>
  • before/after quiz buttons formatted = formatted.replace(/
    (