-
🌐 async-utils.jsWeb, 3.69 kB⬇
-
🌐 auth-storage.jsWeb, 735 B⬇
-
🌐 chat-helpers.jsWeb, 8.85 kB⬇
-
🌐 chat-renderer.jsWeb, 70.00 kB⬇
-
🌐 code-overlay.jsWeb, 21.77 kB⬇
-
🌐 confirm-dialog.jsWeb, 3.17 kB⬇
-
🌐 conversation-format-utils.jsWeb, 21.51 kB⬇
-
🌐 diagram-overlay.jsWeb, 7.93 kB⬇
-
🌐 dialog.jsWeb, 2.50 kB⬇
-
🌐 dom-utils.jsWeb, 8.33 kB⬇
-
🌐 drag-reorder.jsWeb, 4.82 kB⬇
-
🌐 elements.jsWeb, 11.06 kB⬇
-
🌐 export-utils.jsWeb, 1.53 kB⬇
-
🌐 format-utils.jsWeb, 29.60 kB⬇
-
🌐 history-navigation.jsWeb, 1.95 kB⬇
-
🌐 hljs-yaml.jsWeb, 5.07 kB⬇
-
🌐 inline-format.jsWeb, 7.35 kB⬇
-
🌐 instant-answer.jsWeb, 19.82 kB⬇
-
🌐 interactive-tools.jsWeb, 38.77 kB⬇
-
🌐 mermaid-fixes.jsWeb, 4.60 kB⬇
-
🌐 mermaid-render.jsWeb, 5.10 kB⬇
-
🌐 mermaid.jsWeb, 195 B⬇
-
🌐 migrations.jsWeb, 12.11 kB⬇
-
🌐 multi-choice-dialog.jsWeb, 1.33 kB⬇
-
🌐 notification-stack.jsWeb, 2.50 kB⬇
-
🌐 options-manager.jsWeb, 45.56 kB⬇
-
🌐 overlay-utils.jsWeb, 4.14 kB⬇
-
🌐 pdf-export.jsWeb, 23.59 kB⬇
-
🌐 pinch-zoom.jsWeb, 3.21 kB⬇
-
🌐 plugin-dialog.jsWeb, 2.17 kB⬇
-
🌐 read-aloud.jsWeb, 33.84 kB⬇
-
🌐 response-pipeline.jsWeb, 55.19 kB⬇
-
🌐 scratchpad-editor.jsWeb, 8.64 kB⬇
-
🌐 scratchpad-export.jsWeb, 3.56 kB⬇
-
🌐 scrollbars.jsWeb, 3.12 kB⬇
-
🌐 selection-manager.jsWeb, 3.76 kB⬇
-
🌐 settings-utils.jsWeb, 31.08 kB⬇
-
🌐 share.jsWeb, 10.75 kB⬇
-
🌐 side-drawer.jsWeb, 9.37 kB⬇
-
🌐 speech-text.jsWeb, 6.60 kB⬇
-
🌐 swipe-navigation.jsWeb, 6.16 kB⬇
-
🌐 table-overlay.jsWeb, 8.10 kB⬇
-
🌐 theme-utils.jsWeb, 8.34 kB⬇
-
🌐 thinking-panel-base.jsWeb, 14.88 kB⬇
-
🌐 ui-sets.jsWeb, 1.00 kB⬇
-
🌐 version-overlay.jsWeb, 4.63 kB⬇
/**
* Formatting and string utility helpers for TinAI.
*/
import hljs from 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.12.0/es/highlight.min.js';
import hljsYaml from './hljs-yaml.js';
import { strip_tags } from './dom-utils.js';
// Replace the stock yaml grammar with the patched local one (see util/hljs-yaml.js).
hljs.registerLanguage('yaml', hljsYaml);
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);
});
}
/**
* Computes a short hex hash of a string (djb2 xor-shift variant). Used as a
* cheap change detector, e.g. to notice when a conversation no longer matches
* its shared copy; not cryptographic.
* @param {string} text - The text to hash.
* @returns {string} The 8-character hex hash.
*/
export function hashString(text) {
let hash = 5381;
for (let i = 0; i < text.length; i++) {
hash = ((hash << 5) + hash) ^ text.charCodeAt(i);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
/**
* 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, ''');
}
/**
* Resolves a header block's heading level from its index field (1-6).
* @param {Object|null} item - The header block; index is optional.
* @returns {number} The level 1-6, defaulting to 1.
*/
export function headerLevel(item) {
const level = item?.index;
return (Number.isInteger(level) && level >= 1 && level <= 6) ? level : 1;
}
/**
* 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 = '', guid = '') {
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)}"` : '';
const guidAttr = guid ? ` data-guid="${escapeHtml(guid)}"` : '';
let html = '';
if (caption) {
html += `<h7>${escapeHtml(caption)}</h7>`;
}
html += `<pre class="code-block sb-x" data-simplebar data-simplebar-auto-hide="false"${guidAttr}><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);
}
});
}
/**
* Typesets MathJax equations within a container once MathJax has loaded.
* @param {HTMLElement|Element|null} container - The container to typeset.
* @returns {Promise<void>}
*/
export async function typesetMathjax(container) {
const mathjax = /** @type {{typesetPromise?: function(Element[]): Promise<void>}|undefined} */ (globalThis["MathJax"]);
if (!mathjax?.typesetPromise || !container) return;
try {
await mathjax.typesetPromise([container]);
} catch (e) {
console.error('MathJax render error:', e);
}
}
/**
* Closes any HTML tags left open in a raw thinking line so the line cannot
* break the layout of the surrounding thinking block.
* @param {string} html - Raw HTML or text from a thinking line.
* @returns {string} HTML string with all opened tags closed.
*/
function balanceThinkingLineHtml(html) {
const tpl = document.createElement('template');
tpl.innerHTML = html;
return tpl.innerHTML;
}
/**
* 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">${balanceThinkingLineHtml(String(line))}</div>`;
}
if (line.startsWith('<div class="div-thinking-line')) {
return line;
}
if (line.startsWith('SUMMARY:')) {
return `<div class="div-thinking-line div-thinking-flagged"><span class="span-thinking-flag-badge">Summary</span> ${line.substring(8)}</div>`;
}
if (line.startsWith('REDACTED:')) {
return `<div class="div-thinking-line div-thinking-flagged"><span class="span-thinking-flag-badge">Redacted</span> ${escapeHtml(line.substring(9))}</div>`;
}
if (line.startsWith('INPUTREQ:')) {
const jsonStr = line.substring(9).trim();
try {
const data = JSON.parse(jsonStr);
const toolName = data.friendly_name || data.name || 'Input';
const params = data.params || {};
const promptText = typeof params.prompt === 'string' ? params.prompt : '';
const titleAttr = data.name ? ` title="${escapeHtml(data.name)}"` : '';
const requestAttr = data.request_id ? ` data-request-id="${escapeHtml(data.request_id)}"` : '';
const promptHtml = promptText ? ` <span class="span-input-request-prompt">${escapeHtml(promptText)}</span>` : '';
return `<div class="div-thinking-line div-thinking-tool div-input-request"${titleAttr}${requestAttr}><span class="span-tool-badge">Input:</span> <strong class="strong-tool-name">${escapeHtml(toolName)}</strong>${promptHtml}</div>`;
} catch (e) {
return `<div class="div-thinking-line div-thinking-tool"><span class="span-tool-badge">Input:</span> <code>${escapeHtml(jsonStr)}</code></div>`;
}
}
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">${balanceThinkingLineHtml(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;
let container_class = is_expanded ? 'thinking-expanded' : 'thinking-collapsed';
if (is_pending) container_class += ' thinking-streaming';
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 '';
}
}
/**
* Rasterizes every rendered mermaid diagram under a root element into a PNG
* data URI keyed by the item's guid, for exports that need the same images
* more than once (PDF embed and HTML/markdown conversion).
* @param {HTMLElement} el - The rendered content element.
* @param {Array<Object>} [items] - Content items; only diagram items with a guid are collected.
* @returns {Promise<Object>} PNG data URIs keyed by item guid.
*/
export async function rasterize_diagrams(el, items = []) {
const images = {};
for (const item of (Array.isArray(items) ? items : [])) {
if (item?.type !== 'diagram-mermaid' || !item.guid) continue;
const svg = el.querySelector(`[data-guid="${item.guid}"] svg`);
if (svg) {
images[item.guid] = await svgToImageDataUrl(svg);
}
}
return images;
}
/**
* Returns a code block's text without injected chrome (the expand button), so
* clipboard copies and exports contain only the code.
* @param {HTMLElement} node - The pre or code element.
* @returns {string} The code text.
*/
function code_text(node) {
const clone = node.cloneNode(true);
clone.querySelectorAll('.div-code-expand-btn').forEach(btn => btn.remove());
return clone.innerText || clone.textContent || '';
}
/**
* 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.
* @param {Object|null} [images=null] - Pre-rasterized PNG data URIs keyed by diagram
* item guid; diagrams found in the map skip their own rasterization.
* @returns {Promise<{ text: string, html: string }>}
*/
export async function extractFormattedContent(element, images = null) {
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 guid = element.closest?.('[data-guid]')?.getAttribute('data-guid');
const dataUri = (guid && images?.[guid]) || 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 guid = node.closest('[data-guid]')?.getAttribute('data-guid');
const dataUri = (guid && images?.[guid]) || 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 = code_text(node);
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')
};
}
/**
* The stylesheet for the mobile export variant: fluid layout tuned for
* narrow (~3 inch) screens, with overflowing code and tables scrolling
* inside their box instead of widening the page.
*/
const MOBILE_EXPORT_CSS = `body{margin:0;padding:12px;font:16px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;word-wrap:break-word;-webkit-text-size-adjust:100%}
h1{font-size:1.5em;margin:.6em 0 .4em}h2{font-size:1.3em;margin:.6em 0 .4em}h3{font-size:1.15em;margin:.6em 0 .4em}h4,h5,h6{font-size:1em;margin:.6em 0 .4em}
ul,ol{padding-left:1.3em;margin:.4em 0}
pre{overflow-x:auto;font-size:13px}
img,table{max-width:100%}
table{display:block;overflow-x:auto}`;
/**
* Builds a standalone HTML5 document for the file exports. The mobile
* variant adds a viewport that keeps pinch-zooming available and the
* narrow-screen stylesheet.
* @param {string} title - The raw document title.
* @param {string} body - The body HTML fragment.
* @param {boolean} [mobile=false] - Whether to emit the mobile variant.
* @returns {string} The complete HTML5 document.
*/
export function build_export_html(title, body, mobile = false) {
const extra = mobile
? '<meta name="viewport" content="width=device-width, initial-scale=1">\n<style>\n' + MOBILE_EXPORT_CSS + '\n</style>\n'
: '';
return `<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n${extra}<title>${escapeHtml(strip_tags(title || ''))}</title>\n</head>\n<body>\n${body}\n</body>\n</html>\n`;
}
/**
* Builds the collapsible "thoughts" block used by every thinking log: the
* thinking panels (util/thinking-panel-base.js), the code analysis overlay,
* and instant answers. One source for the markup so the streaming class
* contract (see ThinkingPanelBase.end_run) stays identical everywhere.
* @param {string} [summary='Thinking'] - The collapsed summary line.
* @param {string} [content=''] - Pre-rendered lines, for finished blocks.
* @param {boolean} [open=true] - Open and marked streaming while lines arrive;
* false renders the closed, finished block.
* @returns {string} The block html.
*/
export function build_thinking_block(summary = 'Thinking', content = '', open = true) {
return '<details' + (open ? ' open' : '') + ' class="instant-answer-thoughts"><summary>' + escapeHtml(summary) + '</summary><div class="instant-answer-thoughts-content' + (open ? ' streaming' : '') + '">' + content + '</div></details>';
}
/**
* Builds a structured finding card shared by the thinking panels and the
* notes overlay prompt: a titled card with a typed badge, referencing the
* edited item by guid, with anchor tags and inline handlers stripped.
* @param {Object} item - The structured finding { guid, type, title, analysis }.
* @param {boolean} [selected=false] - Whether this card is the batch's selected finding.
* @returns {string} The card html.
*/
export function finding_card_html(item, selected = false) {
const type = String(item.type || '').toUpperCase();
const safe_type = ['EXPLANATION', 'CAUTION', 'WARNING'].includes(type) ? type : 'EXPLANATION';
const guid_attr = item.guid ? ' data-guid="' + escapeHtml(item.guid) + '"' : '';
const analysis = String(item.analysis || '')
.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
.replace(/<\/?a\b[^>]*>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
return '<div class="code-analysis-item' + (selected ? ' selected' : '') + '"' + guid_attr + ' data-type="' + safe_type + '"><div class="code-analysis-item-title"><span class="code-analysis-item-type">' + safe_type + '</span>' + escapeHtml(item.title || 'Finding') + '</div><div class="code-analysis-item-analysis">' + analysis + '</div></div>';
}
format-utils.js
×
Type: Web, text/x-java
29.60 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/x-java
29.60 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File