-
🌐 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⬇
/**
* Conversation and response content formatting utilities for TinAI.
* Converts API response content types (text, code, tables, diagrams, etc.) and history turns into styled HTML.
*/
import Api from '../api.js';
import {
escapeHtml,
formatRelatedChipsHtml,
formatSuggestedChipsHtml,
formatDividerHtml,
formatThinkingBlockHtml,
formatCodeBlockHtml,
headerLevel
} from './format-utils.js';
import { getModelRates, formatModelShortName } from './settings-utils.js';
/**
* Builds a data-guid attribute for an item's rendered root element, so
* scratchpad items can be addressed by guid (table of contents scrolling,
* edit flash highlights). Plain chat content carries no guids.
* @param {Object} item - The content item object.
* @returns {string} The attribute string, empty when the item has no guid.
*/
function guidAttr(item) {
return (item && typeof item.guid === 'string' && item.guid)
? ' data-guid="' + escapeHtml(item.guid) + '"'
: '';
}
/**
* Formats a header item, using its index as the heading level (1-6, default 1).
* @param {Object} item - The content item object.
* @returns {string} HTML string for the header.
*/
export function formatHeader(item) {
const val = (item && item.value !== undefined) ? item.value : '';
const level = headerLevel(item);
return '<h' + level + guidAttr(item) + '>' + val + '</h' + level + '>';
}
/**
* Formats a text paragraph.
* @param {Object} item - The content item object.
* @returns {string} HTML string for the paragraph.
*/
export function formatText(item) {
const val = (item && item.value !== undefined) ? item.value : '';
return '<p' + guidAttr(item) + '>' + val + '</p>';
}
/**
* Formats a code block with syntax highlighting support and escaping.
* @param {Object} item - The content item object containing code and language.
* @returns {string} HTML string for the code block.
*/
export function formatCode(item) {
const val = (item && item.value !== undefined) ? item.value : '';
return formatCodeBlockHtml(val, item?.language, item?.caption, item?.guid);
}
/**
* Formats a blockquote.
* @param {Object} item - The content item object.
* @returns {string} HTML string for the blockquote.
*/
export function formatQuote(item) {
const val = (item && item.value !== undefined) ? item.value : '';
let html = '';
if (item?.caption) html += '<h7>' + item.caption + '</h7>';
html += '<blockquote' + guidAttr(item) + '>' + val + '</blockquote>';
return html;
}
/**
* Formats a hyperlink, using source metadata if available.
* @param {Object} item - The content item object.
* @returns {string} HTML string for the link.
*/
export function formatLink(item) {
const link_title = item?.source?.[0]?.title;
const link_href = item?.source?.[0]?.url;
if (link_title && link_href) {
return '<a target="_blank" href="' + link_href + '"' + guidAttr(item) + '>' + link_title + '\u2197</a>';
}
const val = (item && item.value !== undefined) ? item.value : '';
return '<a' + guidAttr(item) + '>' + val + '</a>';
}
/**
* Formats a color preview.
* @param {Object} item - The content item object containing a hex color.
* @returns {string} HTML string for the color span.
*/
export function formatColor(item) {
const val = (item && item.value !== undefined) ? item.value : '';
let html = '';
if (item?.caption) html += '<h7>' + item.caption + '</h7>';
html += '<span' + guidAttr(item) + ' style="color: ' + val + '">' + val + '</span>';
return html;
}
/**
* Formats a list item (ordered or unordered).
* @param {Object|Array} item - The content item object or list of items.
* @returns {string} HTML string for the list item.
*/
export function formatList(item) {
if (Array.isArray(item)) {
if (item.length === 0) return '';
const first = item[0];
if (first && first.ordered) {
let html = '<ol>';
let next_index = 1;
item.forEach(li => {
if (!li) return;
const index = Number.isInteger(li.index) ? li.index : next_index;
html += '<li value="' + index + '"' + guidAttr(li) + '>' + (li.value || '') + '</li>';
next_index = index + 1;
});
html += '</ol>';
return html;
} else {
let html = '<ul>';
item.forEach(li => {
if (li) html += '<li' + guidAttr(li) + '>' + (li.value || '') + '</li>';
});
html += '</ul>';
return html;
}
}
if (!item) return '';
if (item.ordered) {
return '<ol><li value="' + (item.index || 1) + '"' + guidAttr(item) + '>' + (item.value || '') + '</li></ol>';
}
return '<ul><li' + guidAttr(item) + '>' + (item.value || '') + '</li></ul>';
}
/**
* Formats a Mermaid diagram container.
* @param {Object} item - The content item object containing diagram syntax.
* @returns {string} HTML string for the diagram.
*/
export function formatMermaid(item) {
const val = (item && item.value !== undefined) ? item.value : '';
let html = '';
if (item?.caption) html += '<h7>' + item.caption + '</h7><br/>';
html += '<div class="div-diagram-mermaid"' + guidAttr(item) + ' data-mermaid="' + escapeHtml(val) + '">' + escapeHtml(val) + '</div>';
return html;
}
/**
* Formats a table structure.
* @param {Object} item - The content item object containing table rows and headers.
* @returns {string} HTML string for the table.
*/
export function formatTable(item) {
if (!item || !Array.isArray(item['table-rows'])) return '';
const data = {
caption: item.caption || '',
'has-header': Boolean(item['has-header']),
'table-rows': item['table-rows']
};
let html = '<div class="div-table-block"' + guidAttr(item) + ' data-table="' + escapeHtml(JSON.stringify(data)) + '">';
html += '<div class="div-table-block-header">';
if (item.caption) html += '<h7>' + item.caption + '</h7>';
html += '</div>';
html += '<div class="div-table-scroll sb-x" data-simplebar data-simplebar-auto-hide="false">';
const has_th = data['has-header'];
const rows = [...item['table-rows']];
html += '<table>';
if (has_th && rows.length > 0) {
const row = rows.shift();
html += '<thead><tr>';
for (const title of row) html += '<th>' + title + '</th>';
html += '</tr></thead>';
}
html += '<tbody>';
for (const row of rows) {
html += '<tr>';
if (Array.isArray(row)) {
for (const cell of row) html += '<td>' + cell + '</td>';
}
html += '</tr>';
}
html += '</tbody></table>';
html += '</div>';
html += '</div>';
return html;
}
/**
* Formats a mathematical LaTeX equation.
* @param {Object} item - The content item object containing equation text.
* @returns {string} HTML string for the equation.
*/
export function formatEquation(item) {
let html = '';
if (item?.caption) html += '<h7>' + item.caption + '</h7>';
let val = (item?.value || '').trim();
if (val.startsWith('$$') && val.endsWith('$$') && val.length >= 4) {
val = val.slice(2, -2).trim();
} else if (val.startsWith('\\[') && val.endsWith('\\]') && val.length >= 4) {
val = val.slice(2, -2).trim();
}
html += '<span' + guidAttr(item) + '>$$ ' + val + ' $$</span>';
return html;
}
/**
* Default formatter for unknown or unrecognized content types.
* @param {Object|string} item - The content item object or string.
* @returns {string} HTML string for the preformatted value.
*/
export function formatDefault(item) {
const val = (item && item.value !== undefined) ? item.value : (typeof item === 'string' ? item : (item ? JSON.stringify(item) : ''));
return '<pre' + guidAttr(item) + '>' + val + '</pre>';
}
/**
* Routes a content item to its specific formatter based on its type.
* @param {Object|Array|string} item - The content item object.
* @returns {string} The practical HTML string.
*/
export function formatItem(item) {
if (!item) return '';
if (typeof item === 'string') {
return formatText({ type: 'text', value: item });
}
if (Array.isArray(item)) {
return formatList(item);
}
switch (item.type) {
case 'header':
return formatHeader(item);
case 'text':
return formatText(item);
case 'code':
return formatCode(item);
case 'quote':
return formatQuote(item);
case 'link':
return formatLink(item);
case 'color-hex':
return formatColor(item);
case 'list-step':
return formatList(item);
case 'diagram-mermaid':
return formatMermaid(item);
case 'table':
return formatTable(item);
case 'equation':
return formatEquation(item);
default:
return formatDefault(item);
}
}
/**
* Formats suggested queries and related topics into chip elements.
* @param {Object} data - Turn response object.
* @param {Object} [conversation_settings={}] - Conversation configuration flags.
* @returns {string} The formatted HTML string for suggestions.
*/
export function formatSuggestions(data, conversation_settings = {}) {
let html = '';
const show_suggested = conversation_settings.show_suggested !== false;
const show_related = conversation_settings.show_related !== false;
if (show_suggested && Array.isArray(data.suggestions) && data.suggestions.length > 0) {
const suggestion_spans = formatSuggestedChipsHtml(data.suggestions);
html += `<p><strong>Suggestions:</strong> <i>${suggestion_spans}</i></p>`;
}
if (show_related && Array.isArray(data.related) && data.related.length > 0) {
const related_spans = formatRelatedChipsHtml(data.related);
html += `<p><strong>Related:</strong> <i>${related_spans}</i></p>`;
}
return html;
}
/**
* Formats a content structure by rendering items and grouping consecutive list-steps into lists.
* @param {Array<Object>|Object|string|null} content - Response content items.
* @returns {string} Formatted HTML string.
*/
export function formatContentBlock(content) {
if (!content) return '';
if (typeof content === 'string') {
return formatText({ type: 'text', value: content }) + formatDividerHtml();
}
let response_html = '';
let collected_list_items = [];
const flush_list = () => {
if (collected_list_items.length > 0) {
response_html += formatItem(collected_list_items);
response_html += formatDividerHtml();
collected_list_items = [];
}
};
const content_items = Array.isArray(content) ? content : Object.values(content);
content_items.forEach(item => {
if (!item) return;
if (typeof item === 'string') {
flush_list();
response_html += formatText({ type: 'text', value: item });
response_html += formatDividerHtml();
return;
}
if (item.type === 'list-step') {
let last_collected = collected_list_items[collected_list_items.length - 1];
if (item.ordered && !Number.isInteger(item.index)) {
// LLM omitted the index: continue the count from the previous ordered step
const prev_index = (last_collected && last_collected.ordered && Number.isInteger(last_collected.index)) ? last_collected.index : 0;
item = Object.assign({}, item, { index: prev_index + 1 });
}
if (collected_list_items.length > 0) {
last_collected = collected_list_items[collected_list_items.length - 1];
const same_order_type = item.ordered === last_collected.ordered;
let in_order = true;
if (item.ordered) {
in_order = (item.index === last_collected.index + 1);
}
if (same_order_type && in_order) {
collected_list_items.push(item);
} else {
flush_list();
collected_list_items.push(item);
}
} else {
collected_list_items.push(item);
}
} else {
flush_list();
response_html += formatItem(item);
response_html += formatDividerHtml();
}
});
flush_list();
return response_html;
}
/**
* Formats source citation links.
* @param {Array<Object>|null} annotations - Source citations array.
* @returns {string} Formatted HTML string.
*/
export function formatAnnotationsBlock(annotations) {
if (!Array.isArray(annotations) || annotations.length === 0) return '';
let sources_html = '<div class="div-response-sources" style="margin-top: 1em; opacity: 0.85;">';
sources_html += '<p><strong>Sources:</strong></p>';
sources_html += '<ul style="list-style: none; margin: 0; padding-left: 0.5em;">';
annotations.forEach(ann => {
if (ann && ann.url && ann.title) {
sources_html += `<li style="margin-bottom: 0.3em;"><a target="_blank" href="${ann.url}" style="text-decoration: underline;">${ann.title}\u2197</a></li>`;
}
});
sources_html += '</ul>';
sources_html += '</div>';
return sources_html;
}
/**
* Formats the cost and token usage summary for a response.
* @param {Object} data - The response data containing model, tokens, and costs.
* @returns {string} HTML string for the cost summary.
*/
export function formatCostSummary(data) {
if (!data) {
return '';
}
const promptTokens = data.tokens?.prompt || 0;
const replyTokens = data.tokens?.reply || data.tokens?.response || 0;
const thoughtTokens = data.tokens?.thought || 0;
const totalTokens = data.tokens?.total || (promptTokens + replyTokens + thoughtTokens);
if (!data.costs && !data.tokens && !data.model) {
return '';
}
let total_cost = '0.000';
let op = '?';
let feature_cost = '0.000';
if (data.costs && (data.costs.total !== undefined && data.costs.total !== null)) {
total_cost = Number(data.costs.total).toFixed(3);
op = data.costs.op !== undefined ? Number(data.costs.op).toFixed(3) : '?';
if (data.costs.special_features !== undefined && data.costs.special_features !== null) {
feature_cost = Number(data.costs.special_features).toFixed(3);
}
} else {
const modelKey = data.model || 'gemini-3.5-flash-lite';
const [rateInput, rateOutput] = getModelRates(modelKey);
const promptCost = (promptTokens / 1000000) * rateInput * 1.5;
const replyCost = (replyTokens / 1000000) * rateOutput * 2.0;
const thoughtCost = (thoughtTokens / 1000000) * rateOutput * 2.0;
const featCost = (data.annotations?.length || 0) * 0.014;
const computedTotal = promptCost + replyCost + thoughtCost + featCost;
total_cost = computedTotal.toFixed(3);
feature_cost = featCost.toFixed(3);
}
let model_name = data.model || 'Model';
if (Api.PRESETS && Api.PRESETS[data.model]) {
const p = Api.PRESETS[data.model];
model_name = `${p.name}${p.description ? ` (${p.description})` : ''}`;
} else if (Api.UTILITY_MODELS && Api.UTILITY_MODELS[data.model]) {
const p = Api.UTILITY_MODELS[data.model];
model_name = `${p.name}${p.description ? ` (${p.description})` : ''}`;
} else if (data.model_key && Api.UTILITY_MODELS && Api.UTILITY_MODELS[data.model_key]) {
const p = Api.UTILITY_MODELS[data.model_key];
model_name = `${p.name}${p.description ? ` (${p.description})` : ''}`;
} else {
model_name = formatModelShortName(data.family, data.model || data.model_version, Api.MODELS);
}
let tools_part = '';
const specialFeatures = data.specialFeatures || data.special_features || [];
const toolUsage = data.tools || [];
const counts = {};
if (Array.isArray(toolUsage)) {
for (const tool of toolUsage) {
if (tool) {
const name = typeof tool === 'object' ? (tool.name || tool.type || JSON.stringify(tool)) : String(tool);
counts[name] = (counts[name] || 0) + 1;
}
}
} else if (typeof toolUsage === 'object' && toolUsage !== null) {
for (const [name, count] of Object.entries(toolUsage)) {
counts[name] = (counts[name] || 0) + (typeof count === 'number' ? count : 1);
}
}
const toolsCounted = new Set(Object.keys(counts));
if (Array.isArray(specialFeatures)) {
for (const feat of specialFeatures) {
if (feat) {
const name = typeof feat === 'object' ? (feat.name || feat.type || JSON.stringify(feat)) : String(feat);
if (!toolsCounted.has(name)) {
counts[name] = (counts[name] || 0) + 1;
}
}
}
}
const list = Object.entries(counts).map(([name, count]) => count > 1 ? `${name} (${count})` : name).join(', ');
if (list) {
tools_part = `Tools: ${list}`;
}
let formattedTime = '';
if (data.time !== undefined && data.time !== null && data.time !== '' && !isNaN(Number(data.time))) {
const seconds = Number(data.time) / 1000;
formattedTime = `${seconds.toFixed(2)}s`;
}
const formattedOp = (op !== '?' && !String(op).startsWith('$')) ? `$${op}` : op;
const formattedFeatureCost = `$${feature_cost}`;
const formattedCost = `$${total_cost}`;
const timeDisplay = formattedTime || '-';
const secondaryRow = `<tr class="tr-cost-secondary-row"><td colspan="9"><div class="div-cost-secondary-grid"><div class="div-cost-secondary-cell"><span class="span-cost-metric-label">Total:</span><span class="span-cost-metric-val">${formattedCost}</span></div><div class="div-cost-secondary-cell"><span class="span-cost-metric-label">OP:</span><span class="span-cost-metric-val">${formattedOp}</span></div><div class="div-cost-secondary-cell"><span class="span-cost-metric-label">Time:</span><span class="span-cost-metric-val">${timeDisplay}</span></div></div></td></tr>`;
const toolsRow = tools_part ? `<tr class="tr-cost-tools"><td colspan="9">${escapeHtml(tools_part)}</td></tr>` : '';
return `<div class="div-cost-summary-container"><table class="table-cost-summary"><thead><tr><th>Model</th><th>Input</th><th>Replied</th><th>Thought</th><th>Tokens</th><th class="th-cost-secondary">Feature Cost</th><th class="th-cost-secondary">Total Cost</th><th class="th-cost-secondary">OP</th><th class="th-cost-secondary">Time</th></tr></thead><tbody><tr><td>${escapeHtml(model_name)}</td><td>${promptTokens}</td><td>${replyTokens}</td><td>${thoughtTokens}</td><td>${totalTokens}</td><td class="td-cost-secondary">${formattedFeatureCost}</td><td class="td-cost-secondary">${formattedCost}</td><td class="td-cost-secondary">${formattedOp}</td><td class="td-cost-secondary">${formattedTime}</td></tr>${secondaryRow}${toolsRow}</tbody></table></div>`;
}
/**
* Generates the HTML string for the history item header (query).
* @param {string} query - The user's query.
* @param {boolean} is_related - Whether the query is related to the previous turn.
* @param {number} index - The index of the item.
* @param {boolean} [read_only=false] - When true, renders only the Copy button
* (used by the shared viewer; no read-aloud, redo or delete controls).
* @returns {string} The HTML string for the history item header.
*/
export function formatHistoryItemHeader(query, is_related, index, read_only = false) {
const prefix = is_related ? '... ' : '';
const left_buttons = '<button class="btn-copy-response btn-compact" data-index="' + index + '">Copy</button>';
if (read_only) {
return `
<h5>${prefix}${query}</h5>
<div class="div-chat-response-buttons">
<div class="div-chat-response-buttons-left">${left_buttons}</div>
</div>
`;
}
return `
<h5>${prefix}${query}</h5>
<div class="div-chat-response-buttons">
<div class="div-chat-response-buttons-left">${left_buttons}</div>
<div class="div-chat-read-controls">
<button class="btn-read-prev btn-compact as-icon" data-index="${index}" title="Previous item">⏮</button>
<button class="btn-read-toggle btn-compact as-icon" data-index="${index}" title="Read aloud">▶</button>
<button class="btn-read-next btn-compact as-icon" data-index="${index}" title="Next item">⏯</button>
<label class="label-sync-switch label-read-option" title="Auto-advance">
<input type="checkbox" class="input-read-auto" role="switch" aria-label="Auto-advance">
<span class="span-sync-switch"></span>
<span class="span-read-repeat as-icon" aria-hidden="true">🔁</span>
</label>
<label class="label-sync-switch label-read-option" title="Auto-play new replies">
<input type="checkbox" class="input-read-autoplay" role="switch" aria-label="Auto-play new replies">
<span class="span-sync-switch"></span>
<span class="span-read-repeat as-icon" aria-hidden="true">⏺︎</span>
</label>
<span class="span-read-loading index-item-indicator as-icon" data-index="${index}" style="display: none;" title="Loading audio...">●</span>
</div>
<div class="div-chat-response-buttons-right">
<button class="btn-redo-response btn-compact" data-index="${index}">Redo</button>
<button class="btn-delete-response btn-compact" data-index="${index}">Delete</button>
</div>
</div>
`;
}
/**
* Formats an entire history item from the API, including the query, response content,
* suggestions, and token/cost metadata. The item is wrapped in a div-chat-item
* container so its toolbar can stick to the top of the scroll area while the
* item is in view.
* @param {Object} data - The complete response object for a single turn.
* @param {number} [index=0] - The index of the item in the conversation history.
* @param {Object} [conversation_settings={}] - Object containing settings for the current conversation.
* @returns {string} The complete HTML representation of the history item.
*/
export function formatHistoryItem(data, index = 0, conversation_settings = {}) {
if (!data) return '';
let html = '<div class="div-chat-item">';
html += formatDividerHtml(index);
html += formatHistoryItemHeader(data.query || '', data.chain, index, !!conversation_settings.read_only);
html += formatDividerHtml();
html += formatThinkingBlockHtml(data.thinking, !!data.pending, index);
if (data.pending) {
return html + '</div>';
}
let response_html = formatContentBlock(data.content);
response_html += formatAnnotationsBlock(data.annotations);
html += `<div class="div-response-content" data-index="${index}">${response_html}</div>`;
html += formatSuggestions(data, conversation_settings);
html += formatCostSummary(data);
return html + '</div>';
}
/**
* Wraps a formatted cost summary in the shared scrolling costs block used by
* every thinking panel and overlay log. Single source for the block markup so
* the editors and overlays append identical cost blocks.
* @param {string} cost_html - The formatted cost summary html from formatCostSummary.
* @returns {string} The costs block html, or empty when there is no summary.
*/
export function cost_block_html(cost_html) {
if (!cost_html) {
return '';
}
return '<div class="div-document-thinking-costs sb-x" data-simplebar data-simplebar-auto-hide="false">' + cost_html + '</div>';
}
conversation-format-utils.js
×
Type: Web, text/x-java
21.51 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/x-java
21.51 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File