0 directories, 7 files

util

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

/**
 * 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}/>`;
}

/**
 * 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')
	};
}

/**
 * Converts basic inline/block markdown to HTML.
 * If the string already contains HTML tags, returns it as-is.
 * @param {string} text - Raw or partially formatted text line.
 * @returns {string} Formatted HTML string.
 */
export function formatBasicMarkdown(text) {
	if (!text || typeof text !== 'string') return '';
	const str = text.trim();
	if (!str) return '';

	if (/<\/?[a-z][\s\S]*>/i.test(str)) {
		return str;
	}

	if (/^```(?:[a-zA-Z0-9_+-]+)?\s*(.*)$/.test(str)) {
		let code = str.replace(/^```(?:[a-zA-Z0-9_+-]+)?\s*/, '').replace(/```$/, '');
		return `<pre><code>${escapeHtml(code.trim())}</code></pre>`;
	}

	if (str.startsWith('>')) {
		return `<blockquote>${formatBasicMarkdown(str.replace(/^>\s*/, ''))}</blockquote>`;
	}

	const headingMatch = str.match(/^(#{1,6})\s+(.*)$/);
	if (headingMatch) {
		const level = headingMatch[1].length;
		return `<h${level}>${formatBasicMarkdown(headingMatch[2])}</h${level}>`;
	}

	if (/^[-*]\s+(.*)$/.test(str)) {
		return `&bull; ${formatBasicMarkdown(str.replace(/^[-*]\s+/, ''))}`;
	}

	let escaped = escapeHtml(str);
	escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
	escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
	escaped = escaped.replace(/__([^_]+)__/g, '<strong>$1</strong>');
	escaped = escaped.replace(/\*([^*]+)\*/g, '<em>$1</em>');
	escaped = escaped.replace(/_([^_]+)_/g, '<em>$1</em>');
	escaped = escaped.replace(/~~([^~]+)~~/g, '<del>$1</del>');

	return escaped;
}
🌐
format-utils.js ×
Type: Web, text/plain
15.18 Kilobytes
Last Modified 2026-09-05 04:01:54
⬇ Download File