0 directories, 9 files

util

Home / tinai / util
/**
 * 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
} from './format-utils.js';
import { getModelRates, formatModelShortName } from './settings-utils.js';

/**
 * Formats a header item.
 * @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 : '';
	return '<h4>' + val + '</h4>';
}

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

/**
 * 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>' + 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 + '">' + link_title + '\u2197</a>';
	}
	const val = (item && item.value !== undefined) ? item.value : '';
	return '<a>' + 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 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>';
			item.forEach(li => {
				if (li) html += '<li value="' + (li.index || 1) + '">' + (li.value || '') + '</li>';
			});
			html += '</ol>';
			return html;
		} else {
			let html = '<ul>';
			item.forEach(li => {
				if (li) html += '<li>' + (li.value || '') + '</li>';
			});
			html += '</ul>';
			return html;
		}
	}
	if (!item) return '';
	if (item.ordered) {
		return '<ol><li value="' + (item.index || 1) + '">' + (item.value || '') + '</li></ol>';
	}
	return '<ul><li>' + (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" 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 '';
	let html = '';
	if (item.caption) html += '<h7>' + item.caption + '</h7>';
	const has_th = Boolean(item['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>';
	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 += '$$ ' + val + ' $$';
	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>' + 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') {
			if (collected_list_items.length > 0) {
				const 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.
 * @returns {string} The HTML string for the history item header.
 */
export function formatHistoryItemHeader(query, is_related, index) {
	const prefix = is_related ? '... ' : '';
	return `
		<h5>${prefix}${query}</h5>
		<div class="div-chat-response-buttons">
			<div class="div-chat-response-buttons-left">
				<button class="btn-copy-response" data-index="${index}">Copy</button>
			</div>
			<div class="div-chat-response-buttons-right">
				<button class="btn-redo-response" data-index="${index}">Redo</button>
				<button class="btn-delete-response" 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.
 * @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 = '';

	html += formatDividerHtml(index);
	html += formatHistoryItemHeader(data.query || '', data.chain, index);
	html += formatDividerHtml();

	html += formatThinkingBlockHtml(data.thinking, !!data.pending, index);

	if (data.pending) {
		return html;
	}

	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;
}
🌐
conversation-format-utils.js ×
Type: Web, text/x-java
17.28 Kilobytes
Last Modified 2026-09-23 10:30:34
⬇ Download File