0 directories, 9 files

util

Home / tinai / util
/**
 * Formatting and string utility helpers for TinAI.
 */

import hljs from 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/es/highlight.min.js';

export { hljs };

/**
 * Generates a standard RFC4122 version 4 UUID.
 * Uses crypto.randomUUID when available, with a fallback for older environments.
 * @returns {string} UUID string.
 */
export function generateUUID() {
	if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
		return crypto.randomUUID();
	}
	let ts = new Date().getTime();
	if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
		ts += performance.now();
	}
	return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
		const r = (ts + Math.random() * 16) % 16 | 0;
		ts = Math.floor(ts / 16);
		return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
	});
}

/**
 * Safely escapes HTML special characters in a string.
 * @param {string|null|undefined} text - Raw string.
 * @returns {string} Escaped HTML string.
 */
export function escapeHtml(text) {
	if (text === null || text === undefined) return '';
	return String(text)
		.replace(/&/g, '&')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;')
		.replace(/'/g, '&#039;');
}

/**
 * Formats a query chip HTML span.
 * @param {string} text - Chip text content.
 * @param {string} [className='span-query-chip span-clickable-query'] - CSS class names.
 * @param {string} [extraAttrs=''] - Additional HTML attributes.
 * @returns {string} HTML string.
 */
export function formatQueryChip(text, className = 'span-query-chip span-clickable-query', extraAttrs = '') {
	return `<span class="${className}"${extraAttrs ? ' ' + extraAttrs : ''}>${text}</span>`;
}

/**
 * Formats related topics array into linked chips separated by non-breaking spaces.
 * @param {string[]} relatedTopics - List of related query topics.
 * @returns {string} HTML string.
 */
export function formatRelatedChipsHtml(relatedTopics) {
	if (!Array.isArray(relatedTopics) || relatedTopics.length === 0) return '';
	return relatedTopics
		.map(topic => formatQueryChip(topic, 'span-query-chip span-related-query span-clickable-query'))
		.join('&nbsp;');
}

/**
 * Formats suggested queries array into linked chips separated by non-breaking spaces.
 * @param {string[]} suggestions - List of suggestion strings.
 * @returns {string} HTML string.
 */
export function formatSuggestedChipsHtml(suggestions) {
	if (!Array.isArray(suggestions) || suggestions.length === 0) return '';
	return suggestions
		.map(suggestion => formatQueryChip(suggestion, 'span-query-chip span-suggested-query span-clickable-query'))
		.join('&nbsp;');
}

/**
 * Returns a standard horizontal divider HTML string for chat and turn separation.
 * @param {string|number|null} [id=null] - Optional ID or index for the divider.
 * @returns {string} HTML string.
 */
export function formatDividerHtml(id = null) {
	const idAttr = (id !== null && id !== undefined) ? ` id="chat-item-${id}"` : '';
	return `<hr class="hr-chat-response-divider"${idAttr}/>`;
}

/**
 * Formats a code block HTML string with standard pre and code markup.
 * @param {string|null|undefined} code - The code string content.
 * @param {string} [language=''] - The programming language name/identifier.
 * @param {string} [caption=''] - Optional caption title.
 * @returns {string} The formatted HTML string for the code block.
 */
export function formatCodeBlockHtml(code, language = '', caption = '') {
	const val = (code !== undefined && code !== null) ? String(code) : '';
	const escaped = escapeHtml(val);
	const lang = (language && typeof language === 'string') ? language.trim() : '';
	const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
	let html = '';
	if (caption) {
		html += `<h7>${escapeHtml(caption)}</h7>`;
	}
	html += `<pre class="code-block"><code${langAttr}>${escaped}</code></pre>`;
	return html;
}

/**
 * Applies Highlight.js syntax highlighting to all code blocks within a container or a single code element.
 * @param {HTMLElement|Document} [target=document] - Root container or code element.
 */
export function highlightCodeBlocks(target = document) {
	if (!target) return;
	if (typeof target.matches === 'function' && target.matches('pre code, code')) {
		try {
			hljs.highlightElement(target);
		} catch (e) {
			console.warn('Highlight.js failed to highlight element:', e);
		}
		return;
	}
	if (typeof target.querySelectorAll !== 'function') return;
	const codeBlocks = target.querySelectorAll('pre code');
	codeBlocks.forEach((block) => {
		try {
			hljs.highlightElement(block);
		} catch (e) {
			console.warn('Highlight.js failed to highlight code block:', e);
		}
	});
}

/**
 * Formats a single thinking line into HTML, rendering structured tool invocations and results nicely if prefixed with TOOL: or TOOLRESULT:.
 * @param {string} line - The thinking line text or HTML.
 * @returns {string} Formatted HTML representation.
 */
export function formatThinkingLineHtml(line) {
	if (typeof line !== 'string') {
		return `<div class="div-thinking-line">${String(line)}</div>`;
	}
	if (line.startsWith('<div class="div-thinking-line')) {
		return line;
	}
	if (line.startsWith('TOOL:')) {
		const jsonStr = line.substring(5).trim();
		try {
			const toolData = JSON.parse(jsonStr);
			let toolName = toolData.friendly_name || toolData.name || toolData.tool || '';
			let params = toolData.params !== undefined ? toolData.params : (toolData.parameters !== undefined ? toolData.parameters : (toolData.args !== undefined ? toolData.args : (toolData.arguments !== undefined ? toolData.arguments : null)));

			if (!toolName && params === null) {
				params = toolData;
			}
			if (!toolName) {
				toolName = 'Tool';
			}

			let paramsHtml = '()';
			if (params && typeof params === 'object' && Object.keys(params).length > 0) {
				const formattedList = Object.entries(params).map(([k, v]) => {
					const valStr = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v);
					return `<span class="span-tool-param-key">${escapeHtml(k)}:</span> <span class="span-tool-param-val">${escapeHtml(valStr)}</span>`;
				}).join(', ');
				paramsHtml = `(${formattedList})`;
			}

			const titleAttr = toolData.name ? ` title="${escapeHtml(toolData.name)}"` : '';
			return `<div class="div-thinking-line div-thinking-tool"><span class="span-tool-badge">Tool:</span> <strong class="strong-tool-name"${titleAttr}>${escapeHtml(toolName)}</strong> <span class="span-tool-params">${paramsHtml}</span></div>`;
		} catch (e) {
			return `<div class="div-thinking-line div-thinking-tool"><span class="span-tool-badge">Tool:</span> <code>${escapeHtml(jsonStr)}</code></div>`;
		}
	}
	if (line.startsWith('TOOLRESULT:')) {
		const jsonStr = line.substring(11).trim();
		try {
			const toolData = JSON.parse(jsonStr);
			let toolName = toolData.friendly_name || toolData.name || toolData.tool || '';
			let result = toolData.result !== undefined ? toolData.result : (toolData.output !== undefined ? toolData.output : null);

			if (!toolName && result === null) {
				result = toolData;
			}
			if (!toolName) {
				toolName = 'Tool';
			}

			let resultHtml = '';
			if (result !== null && result !== undefined) {
				if (typeof result === 'object' && !Array.isArray(result)) {
					const entries = Object.entries(result);
					if (entries.length > 0) {
						const formattedList = entries.map(([k, v]) => {
							const valStr = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v);
							return `<span class="span-tool-param-key">${escapeHtml(k)}:</span> <span class="span-tool-param-val">${escapeHtml(valStr)}</span>`;
						}).join(', ');
						resultHtml = ` =&gt; {${formattedList}}`;
					} else {
						resultHtml = ' =&gt; {}';
					}
				} else if (Array.isArray(result)) {
					resultHtml = ` =&gt; ${escapeHtml(JSON.stringify(result))}`;
				} else {
					resultHtml = ` =&gt; <span class="span-tool-param-val">${escapeHtml(String(result))}</span>`;
				}
			}

			const titleAttr = toolData.name ? ` title="${escapeHtml(toolData.name)}"` : '';
			return `<div class="div-thinking-line div-thinking-tool div-thinking-tool-result"><span class="span-tool-badge">Result:</span> <strong class="strong-tool-name"${titleAttr}>${escapeHtml(toolName)}</strong><span class="span-tool-params">${resultHtml}</span></div>`;
		} catch (e) {
			return `<div class="div-thinking-line div-thinking-tool div-thinking-tool-result"><span class="span-tool-badge">Result:</span> <code>${escapeHtml(jsonStr)}</code></div>`;
		}
	}
	return `<div class="div-thinking-line">${line}</div>`;
}

/**
 * Formats an expandable thought process block for conversation turns and scratchpad items.
 * @param {Array<string>|string|null|undefined} thinking - Thinking lines array or legacy string.
 * @param {boolean} [is_pending=false] - Whether response is currently streaming.
 * @param {number} [index=0] - Turn index.
 * @returns {string} HTML string for thinking block, or empty string if no thinking.
 */
export function formatThinkingBlockHtml(thinking, is_pending = false, index = 0) {
	let lines = [];
	if (Array.isArray(thinking)) {
		lines = thinking.flatMap(item => typeof item === 'string' ? item.split('\n').map(l => l.trim()).filter(l => l.length > 0) : item);
	} else if (typeof thinking === 'string' && thinking.trim().length > 0) {
		lines = thinking.split('\n').map(l => l.trim()).filter(l => l.length > 0);
	}

	if (lines.length === 0 && !is_pending) {
		return '';
	}

	let thinking_html = 'Thinking...';
	if (lines.length > 0) {
		thinking_html = lines
			.map(line => formatThinkingLineHtml(line))
			.join('');
	}

	const is_expanded = is_pending;
	const container_class = is_expanded ? 'thinking-expanded' : 'thinking-collapsed';
	const display_style = is_expanded ? 'block' : 'none';
	const arrow_char = is_expanded ? '&#x25bc;' : '&#x25b6;';

	return `
		<div class="div-thinking-content ${container_class}" data-index="${index}">
			<p class="p-thinking-header" style="cursor: pointer; user-select: none;">
				<span class="span-thinking-arrow">${arrow_char}</span>
				<strong>Thought Process</strong>
			</p>
			<div class="div-thinking-text" style="display: ${display_style};">
				${thinking_html}
			</div>
		</div>
	` + formatDividerHtml();
}

/**
 * Converts an HTML table element to Markdown table string.
 * @param {HTMLTableElement} tableEl - The table element to convert.
 * @returns {string} Formatted Markdown table.
 */
export function convertTableToMarkdown(tableEl) {
	if (!tableEl) return '';
	const rows = Array.from(tableEl.querySelectorAll('tr'));
	if (rows.length === 0) return '';

	const tableMatrix = rows.map(tr => {
		const cells = Array.from(tr.querySelectorAll('th, td'));
		return cells.map(cell => (cell.innerText || cell.textContent || '').trim().replace(/\|/g, '\\|').replace(/\n+/g, ' '));
	});

	const maxCols = Math.max(...tableMatrix.map(r => r.length), 0);
	if (maxCols === 0) return '';

	tableMatrix.forEach(r => {
		while (r.length < maxCols) r.push('');
	});

	const colWidths = Array(maxCols).fill(3);
	tableMatrix.forEach(row => {
		row.forEach((cell, i) => {
			if (cell.length > colWidths[i]) colWidths[i] = cell.length;
		});
	});

	const formatRow = (r) => '| ' + r.map((c, i) => c.padEnd(colWidths[i], ' ')).join(' | ') + ' |';
	const separatorRow = '| ' + colWidths.map(w => '-'.repeat(Math.max(w, 3))).join(' | ') + ' |';

	const mdRows = [];
	mdRows.push(formatRow(tableMatrix[0]));
	mdRows.push(separatorRow);

	for (let i = 1; i < tableMatrix.length; i++) {
		mdRows.push(formatRow(tableMatrix[i]));
	}

	return mdRows.join('\n');
}

/**
 * Converts an HTML table element to clean styled HTML suitable for clipboard pasting into rich text editors.
 * @param {HTMLTableElement} tableEl - The table element.
 * @returns {string} Styled HTML table string.
 */
export function convertTableToStyledHtml(tableEl) {
	if (!tableEl) return '';
	const clonedTable = tableEl.cloneNode(true);
	clonedTable.removeAttribute('class');
	clonedTable.removeAttribute('id');
	clonedTable.setAttribute('style', 'border-collapse: collapse; width: 100%; border: 1px solid #cccccc; margin: 8px 0;');

	clonedTable.querySelectorAll('th').forEach(th => {
		th.setAttribute('style', 'border: 1px solid #cccccc; padding: 6px 10px; background-color: #f2f2f2; font-weight: bold; text-align: left;');
	});

	clonedTable.querySelectorAll('td').forEach(td => {
		td.setAttribute('style', 'border: 1px solid #cccccc; padding: 6px 10px; text-align: left;');
	});

	return clonedTable.outerHTML;
}

/**
 * Converts an SVG element to a PNG or SVG data URL.
 * @param {SVGElement} svgEl - The SVG element to convert.
 * @returns {Promise<string>} Base64 image data URL.
 */
export async function svgToImageDataUrl(svgEl) {
	if (!svgEl) return '';
	try {
		const clonedSvg = svgEl.cloneNode(true);

		if (!clonedSvg.getAttribute('xmlns')) {
			clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
		}
		if (!clonedSvg.getAttribute('xmlns:xlink')) {
			clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
		}

		const rect = svgEl.getBoundingClientRect ? svgEl.getBoundingClientRect() : {};
		const viewBox = svgEl.viewBox?.baseVal;
		const bbox = svgEl.getBBox ? (() => { try { return svgEl.getBBox(); } catch (e) { return null; } })() : null;

		let width = (viewBox && viewBox.width > 0) ? viewBox.width
			: (bbox && bbox.width > 0) ? bbox.width
			: (rect && rect.width > 0) ? rect.width
			: parseFloat(svgEl.getAttribute('width')) || 800;

		let height = (viewBox && viewBox.height > 0) ? viewBox.height
			: (bbox && bbox.height > 0) ? bbox.height
			: (rect && rect.height > 0) ? rect.height
			: parseFloat(svgEl.getAttribute('height')) || 600;

		width = Math.max(Math.round(width), 100);
		height = Math.max(Math.round(height), 100);

		clonedSvg.setAttribute('width', String(width));
		clonedSvg.setAttribute('height', String(height));
		clonedSvg.removeAttribute('style');

		if (!clonedSvg.getAttribute('viewBox')) {
			clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
		}

		const serializer = new XMLSerializer();
		const svgStr = serializer.serializeToString(clonedSvg);
		const base64Svg = btoa(unescape(encodeURIComponent(svgStr)));
		const svgDataUri = 'data:image/svg+xml;base64,' + base64Svg;

		return await new Promise((resolve) => {
			const img = new Image();
			img.crossOrigin = 'anonymous';
			const timer = setTimeout(() => {
				resolve(svgDataUri);
			}, 600);

			img.onload = () => {
				clearTimeout(timer);
				try {
					const canvas = document.createElement('canvas');
					const scale = 2;
					canvas.width = width * scale;
					canvas.height = height * scale;
					const ctx = canvas.getContext('2d');
					if (ctx) {
						ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
						const pngUri = canvas.toDataURL('image/png');
						if (pngUri && pngUri.startsWith('data:image/png') && pngUri.length > 100) {
							resolve(pngUri);
							return;
						}
					}
				} catch (err) {
					console.warn('Canvas PNG conversion fallback to SVG:', err);
				}
				resolve(svgDataUri);
			};
			img.onerror = () => {
				clearTimeout(timer);
				resolve(svgDataUri);
			};
			img.src = svgDataUri;
		});
	} catch (e) {
		console.error('Failed to convert SVG to image data URL:', e);
		return '';
	}
}

/**
 * Extracts formatted plain text (with Markdown tables and diagram image markdown)
 * and rich HTML (with styled tables and <img> diagram elements) from a response DOM element or string.
 * @param {HTMLElement|string} element - The DOM element or text to extract.
 * @returns {Promise<{ text: string, html: string }>}
 */
export async function extractFormattedContent(element) {
	if (!element) return { text: '', html: '' };
	if (typeof element === 'string') {
		return { text: element, html: `<p>${escapeHtml(element)}</p>` };
	}

	const tag = element.tagName ? element.tagName.toLowerCase() : '';

	if (tag === 'table') {
		const md = convertTableToMarkdown(element);
		const html = convertTableToStyledHtml(element);
		return { text: md, html };
	}

	if (element.classList?.contains('div-diagram-mermaid') || element.classList?.contains('mermaid') || element.classList?.contains('div-diagram-mermaid-rendered') || tag === 'svg') {
		const svgEl = element.querySelector?.('svg') || (tag === 'svg' ? element : null);
		if (svgEl) {
			const dataUri = await svgToImageDataUrl(svgEl);
			const alt = 'Mermaid Diagram';
			return {
				text: `![${alt}](${dataUri})`,
				html: `<div style="margin: 12px 0;"><img src="${dataUri}" alt="${alt}" style="max-width: 100%; height: auto;" /></div>`
			};
		}
		const rawCode = element.getAttribute?.('data-mermaid') || element.textContent?.trim() || '';
		return {
			text: '```mermaid\n' + rawCode + '\n```',
			html: `<pre><code class="language-mermaid">${escapeHtml(rawCode)}</code></pre>`
		};
	}

	const textParts = [];
	const htmlParts = [];
	const children = Array.from(element.childNodes);

	for (let i = 0; i < children.length; i++) {
		const node = children[i];
		if (node.nodeType === Node.TEXT_NODE) {
			const val = node.textContent.trim();
			if (val) {
				textParts.push(val);
				htmlParts.push(`<p>${escapeHtml(val)}</p>`);
			}
			continue;
		}

		if (node.nodeType !== Node.ELEMENT_NODE) continue;

		const nodeTag = node.tagName.toLowerCase();

		if (node.classList.contains('hr-chat-response-divider') || nodeTag === 'hr') {
			continue;
		}

		if (node.classList.contains('div-chat-response-buttons')) {
			continue;
		}

		if (nodeTag === 'table') {
			const mdTable = convertTableToMarkdown(node);
			const styledTable = convertTableToStyledHtml(node);
			textParts.push(mdTable);
			htmlParts.push(styledTable);
			continue;
		}

		if (node.classList.contains('div-diagram-mermaid') || node.classList.contains('mermaid') || node.classList.contains('div-diagram-mermaid-rendered') || node.querySelector('svg') || nodeTag === 'svg') {
			const svgEl = node.querySelector('svg') || (nodeTag === 'svg' ? node : null);
			if (svgEl) {
				const dataUri = await svgToImageDataUrl(svgEl);
				const alt = 'Mermaid Diagram';
				textParts.push(`![${alt}](${dataUri})`);
				htmlParts.push(`<div style="margin: 12px 0;"><img src="${dataUri}" alt="${alt}" style="max-width: 100%; height: auto;" /></div>`);
			} else {
				const rawCode = node.getAttribute?.('data-mermaid') || node.textContent.trim();
				textParts.push('```mermaid\n' + rawCode + '\n```');
				htmlParts.push(`<pre><code class="language-mermaid">${escapeHtml(rawCode)}</code></pre>`);
			}
			continue;
		}

		if (nodeTag === 'pre' || nodeTag === 'code') {
			const code = node.innerText || node.textContent || '';
			const langMatch = node.className?.match(/language-([a-zA-Z0-9_-]+)/) || node.querySelector('code')?.className?.match(/language-([a-zA-Z0-9_-]+)/);
			const lang = langMatch ? langMatch[1] : '';
			textParts.push('```' + lang + '\n' + code.trim() + '\n```');
			htmlParts.push(`<pre><code class="${lang ? 'language-' + lang : ''}">${escapeHtml(code)}</code></pre>`);
			continue;
		}

		if (/^h[1-6]$/.test(nodeTag)) {
			const level = parseInt(nodeTag[1], 10);
			const prefix = '#'.repeat(level);
			textParts.push(`${prefix} ${node.textContent.trim()}`);
			htmlParts.push(`<${nodeTag}>${node.innerHTML}</${nodeTag}>`);
			continue;
		}

		if (nodeTag === 'h7') {
			textParts.push(`*${node.textContent.trim()}*`);
			htmlParts.push(`<p><strong>${node.innerHTML}</strong></p>`);
			continue;
		}

		if (nodeTag === 'blockquote') {
			const quoteLines = node.textContent.trim().split('\n').map(l => '> ' + l).join('\n');
			textParts.push(quoteLines);
			htmlParts.push(`<blockquote>${node.innerHTML}</blockquote>`);
			continue;
		}

		if (nodeTag === 'ul') {
			const lis = Array.from(node.querySelectorAll('li'));
			const listText = lis.map(li => `- ${li.textContent.trim()}`).join('\n');
			textParts.push(listText);
			htmlParts.push(`<ul>${node.innerHTML}</ul>`);
			continue;
		}

		if (nodeTag === 'ol') {
			const lis = Array.from(node.querySelectorAll('li'));
			const listText = lis.map((li, idx) => `${li.value || (idx + 1)}. ${li.textContent.trim()}`).join('\n');
			textParts.push(listText);
			htmlParts.push(`<ol>${node.innerHTML}</ol>`);
			continue;
		}

		if (node.classList.contains('div-response-sources')) {
			const lis = Array.from(node.querySelectorAll('li'));
			const sourcesList = lis.map(li => {
				const a = li.querySelector('a');
				return a ? `- [${a.textContent.trim()}](${a.href})` : `- ${li.textContent.trim()}`;
			}).join('\n');
			textParts.push(`Sources:\n${sourcesList}`);
			htmlParts.push(node.outerHTML);
			continue;
		}

		if (nodeTag === 'p') {
			const textVal = node.textContent.trim();
			if (textVal) {
				textParts.push(textVal);
				htmlParts.push(`<p>${node.innerHTML}</p>`);
			}
			continue;
		}

		if (node.querySelector('table')) {
			const tables = Array.from(node.querySelectorAll('table'));
			tables.forEach(tbl => {
				textParts.push(convertTableToMarkdown(tbl));
				htmlParts.push(convertTableToStyledHtml(tbl));
			});
			continue;
		}

		const fallbackText = node.innerText || node.textContent || '';
		if (fallbackText.trim()) {
			textParts.push(fallbackText.trim());
			htmlParts.push(node.outerHTML || `<p>${escapeHtml(fallbackText)}</p>`);
		}
	}

	return {
		text: textParts.join('\n\n').trim(),
		html: htmlParts.join('\n')
	};
}
🌐
format-utils.js ×
Type: Web, text/x-java
20.91 Kilobytes
Last Modified 2026-09-23 02:24:00
⬇ Download File