/**
* 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, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* 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(' ');
}
/**
* 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(' ');
}
/**
* 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: ``,
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(``);
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 `• ${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
Type: Web, text/plain
15.18 Kilobytes
Last Modified 2026-09-05 04:01:54
⬇ Download File