-
🌐 async-utils.jsWeb, 3.69 kB⬇
-
🌐 confirm-dialog.jsWeb, 3.04 kB⬇
-
🌐 conversation-format-utils.jsWeb, 17.28 kB⬇
-
🌐 dom-utils.jsWeb, 2.92 kB⬇
-
🌐 format-utils.jsWeb, 20.91 kB⬇
-
🌐 overlay-utils.jsWeb, 1.99 kB⬇
-
🌐 selection-manager.jsWeb, 3.76 kB⬇
-
🌐 settings-utils.jsWeb, 22.87 kB⬇
-
🌐 theme-utils.jsWeb, 6.78 kB⬇
/**
* 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, '<')
.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}/>`;
}
/**
* 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 = ` => {${formattedList}}`;
} else {
resultHtml = ' => {}';
}
} else if (Array.isArray(result)) {
resultHtml = ` => ${escapeHtml(JSON.stringify(result))}`;
} else {
resultHtml = ` => <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 ? '▼' : '▶';
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: ``,
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')
};
}
format-utils.js
×
Type: Web, text/x-java
20.91 Kilobytes
Last Modified 2026-09-23 02:24:00
⬇ Download File
Type: Web, text/x-java
20.91 Kilobytes
Last Modified 2026-09-23 02:24:00
⬇ Download File