383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
|
|
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, '"')
|
||
|
|
.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(
|
||
|
|
`<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm font-mono overflow-x-auto my-3 border border-gray-700"><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`
|
||
|
|
);
|
||
|
|
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' ? '</ul>' : '</ol>');
|
||
|
|
inList = false;
|
||
|
|
}
|
||
|
|
outputLines.push(codeBlocks[parseInt(cbMatch[1], 10)]);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
|
||
|
|
if (inList) {
|
||
|
|
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||
|
|
inList = false;
|
||
|
|
}
|
||
|
|
outputLines.push('<hr class="my-4 border-gray-300">');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||
|
|
if (headingMatch) {
|
||
|
|
if (inList) {
|
||
|
|
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||
|
|
inList = false;
|
||
|
|
}
|
||
|
|
const level = headingMatch[1].length;
|
||
|
|
const headingText = headingMatch[2];
|
||
|
|
const sizes: Record<number, string> = {
|
||
|
|
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(`<div class="${sizes[level] || sizes[3]}">${applyInlineFormatting(headingText)}</div>`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const bulletMatch = line.match(/^(\s*)[-*+]\s+(.+)$/);
|
||
|
|
if (bulletMatch) {
|
||
|
|
if (!inList || listType !== 'ul') {
|
||
|
|
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||
|
|
outputLines.push('<ul class="list-disc list-inside space-y-1 my-2 text-gray-700">');
|
||
|
|
inList = true;
|
||
|
|
listType = 'ul';
|
||
|
|
}
|
||
|
|
outputLines.push(`<li class="leading-relaxed">${applyInlineFormatting(bulletMatch[2])}</li>`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const numMatch = line.match(/^(\s*)\d+[.)]\s+(.+)$/);
|
||
|
|
if (numMatch) {
|
||
|
|
if (!inList || listType !== 'ol') {
|
||
|
|
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||
|
|
outputLines.push('<ol class="list-decimal list-inside space-y-1 my-2 text-gray-700">');
|
||
|
|
inList = true;
|
||
|
|
listType = 'ol';
|
||
|
|
}
|
||
|
|
outputLines.push(`<li class="leading-relaxed">${applyInlineFormatting(numMatch[2])}</li>`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (inList && line.trim() !== '') {
|
||
|
|
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||
|
|
inList = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (line.trim() === '') {
|
||
|
|
outputLines.push('<div class="my-3"></div>');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
outputLines.push(`<div class="leading-relaxed">${applyInlineFormatting(line)}</div>`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||
|
|
|
||
|
|
return outputLines.join('\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyInlineFormatting(text: string): string {
|
||
|
|
return text
|
||
|
|
.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-gray-900">$1</strong>')
|
||
|
|
.replace(/\*([^*]+)\*/g, '<em class="italic text-gray-700">$1</em>')
|
||
|
|
.replace(/`([^`]+)`/g, '<code class="bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-[0.85em] font-mono border border-gray-200">$1</code>')
|
||
|
|
.replace(/\bhttps?:\/\/[^\s<&]+/g, (url) => {
|
||
|
|
return `<a href="${url}" class="text-blue-600 hover:text-blue-800 underline underline-offset-2 decoration-blue-300 hover:decoration-blue-500 transition-colors font-medium" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
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 = `</${tagName}>`;
|
||
|
|
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';
|
||
|
|
}
|