2 directories, 29 files

tinai

Home / testing / ai / tinai
import Api from './api.js';
import { escapeHtml, formatRelatedChipsHtml, formatSuggestedChipsHtml, formatDividerHtml } from './util/format-utils.js';

/**
 * class Conversation
 * Handles the formatting and rendering of conversation history items into HTML.
 * Converts various API response content types (text, code, tables, diagrams, etc.) into styled DOM elements.
 */
class Conversation {

	//region Initialization

	/**
	 * Initializes a new instance of the Conversation class.
	 */
	constructor() {
	}

	//endregion

	//region Formatting Helpers

	/**
	 * Formats a header item.
	 * @param {Object} item - The content item object.
	 * @returns {string} HTML string for the header.
	 */
	format_header(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.
	 */
	format_text(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.
	 */
	format_code(item) {
		const val = (item && item.value !== undefined) ? item.value : '';
		const escaped = escapeHtml(val);
		const lang_class = item.language ? ' class="code-block language-' + item.language + '"' : '';
		let html = '';
		if (item.caption) html += '<h7>' + item.caption + '</h7>';
		html += '<code' + lang_class + '><pre>' + escaped + '</pre></code>';
		return html;
	}

	/**
	 * Formats a blockquote.
	 * @param {Object} item - The content item object.
	 * @returns {string} HTML string for the blockquote.
	 */
	format_quote(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.
	 */
	format_link(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.
	 */
	format_color(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.
	 */
	format_list(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 Mermaid syntax.
	 * @returns {string} HTML string for the diagram.
	 */
	format_mermaid(item) {
		const val = (item && item.value !== undefined) ? item.value : '';
		let html = '';
		if (item?.caption) html += '<h7>' + item.caption + '</h7><br/>';
		html += '<pre class="div-diagram-mermaid">' + val + '</pre>';
		return html;
	}

	/**
	 * Formats a data table.
	 * @param {Object} item - The content item object containing table rows and configuration.
	 * @returns {string} HTML string for the table.
	 */
	format_table(item) {
		if (!item || !Array.isArray(item['table-rows'])) return '';
		let html = '';
		if (item.caption) html += '<h7>' + item.caption + '</h7>';
		const has_th = 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 TeX Equation container.
	 * @param {Object} item - The content item object containing TeX syntax.
	 * @returns {string} HTML string for the equation.
	 */
	format_equation(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;
	}

	/**
	 * Fallback formatter for unknown content types.
	 * @param {Object} item - The content item object.
	 * @returns {string} HTML string for the preformatted value.
	 */
	format_default(item) {
		const val = (item && item.value !== undefined) ? item.value : (typeof item === 'string' ? item : (item ? JSON.stringify(item) : ''));
		return '<pre>' + val + '</pre>';
	}

	//endregion

	//region Core Routing

	/**
	 * Routes a content item to its specific formatter based on its type.
	 * @param {Object|Array|string} item - The content item object.
	 * @returns {string} The formatted HTML string.
	 */
	format_item(item) {
		if (!item) return '';
		if (typeof item === 'string') {
			return this.format_text({ type: 'text', value: item });
		}
		if (Array.isArray(item)) {
			return this.format_list(item);
		}
		switch (item.type) {
			case 'header':
				return this.format_header(item);
			case 'text':
				return this.format_text(item);
			case 'code':
				return this.format_code(item);
			case 'quote':
				return this.format_quote(item);
			case 'link':
				return this.format_link(item);
			case 'color-hex':
				return this.format_color(item);
			case 'list-step':
				return this.format_list(item);
			case 'diagram-mermaid':
				return this.format_mermaid(item);
			case 'table':
				return this.format_table(item);
			case 'equation':
				return this.format_equation(item);
			default:
				return this.format_default(item);
		}
	}

	//endregion

	//region Response Metadata & Layout

	/**
	 * Returns a standard horizontal divider for chat history.
	 * @param {string|number|null} [id=null] - Optional ID to assign to the divider.
	 * @returns {string} HTML string for the divider.
	 */
	format_divider(id = null) {
		return formatDividerHtml(id);
	}

	/**
	 * Formats an expandable thought process block.
	 * @param {Array<string>|null} thinking - Thinking lines array.
	 * @param {boolean} [is_pending=false] - Whether response is currently streaming.
	 * @param {number} [index=0] - Turn index.
	 * @returns {string} HTML string for thinking block.
	 */
	format_thinking_block(thinking, is_pending = false, index = 0) {
		if ((!Array.isArray(thinking) || thinking.length === 0) && !is_pending) {
			return '';
		}
		let thinking_text = 'Thinking...';
		if (Array.isArray(thinking) && thinking.length > 0) {
			thinking_text = thinking.map(line => `<div class="div-thinking-line">${line}</div>`).join('');
		}

		const collapse_class = is_pending ? 'thinking-expanded' : 'thinking-collapsed';
		const display_style = is_pending ? 'display: block;' : 'display: none;';

		return `
		<div class="div-thinking-content ${collapse_class}" data-index="${index}">
			<p class="p-thinking-header" style="cursor: pointer; user-select: none;">
				<strong>Thought Process</strong> <span class="span-thinking-arrow" style="font-size: 0.8em; margin-left: 5px;">${is_pending ? '\u25bc' : '\u25b6'}</span>
			</p>
			<div class="div-thinking-text" style="${display_style}">
				${thinking_text}
			</div>
		</div>` + this.format_divider();
	}

	/**
	 * 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.
	 */
	format_content_block(content) {
		if (!content) return '';
		if (typeof content === 'string') {
			return this.format_text({ type: 'text', value: content }) + this.format_divider();
		}
		let response_html = '';
		let collected_list_items = [];
		const flush_list = () => {
			if (collected_list_items.length > 0) {
				response_html += this.format_item(collected_list_items);
				response_html += this.format_divider();
				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 += this.format_text({ type: 'text', value: item });
				response_html += this.format_divider();
				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 += this.format_item(item);
				response_html += this.format_divider();
			}
		});
		flush_list();
		return response_html;
	}

	/**
	 * Formats source citation links.
	 * @param {Array<Object>|null} annotations - Source citations array.
	 * @returns {string} Formatted HTML string.
	 */
	format_annotations_block(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 related topics and suggested follow-up prompts.
	 * @param {Object} data - The response data object.
	 * @param {Object} [conversation_settings={}] - Object containing settings for the current conversation.
	 * @returns {string} HTML string for the related/suggestions section.
	 */
	format_suggestions(data, conversation_settings = {}) {
		if (!data) return '';
		let html = '';
		let has_content = false;

		const show_related = conversation_settings.showRelatedQueries;
		const show_suggestions = conversation_settings.showSuggestedQueries;

		if (show_related && Array.isArray(data.related) && data.related.length > 0) {
			html += this._get_related_html(data.related);
			has_content = true;
		}
		if (show_suggestions && Array.isArray(data.suggestions) && data.suggestions.length > 0) {
			html += this._get_suggestions_html(data.suggestions);
			has_content = true;
		}
		if (has_content) {
			html += this.format_divider();
		}
		return html;
	}

	/**
	 * Generates the HTML string for related topics.
	 * @param {Array<string>} related_topics - An array of related topic strings.
	 * @returns {string} The HTML string for related topics.
	 */
	_get_related_html(related_topics) {
		const related_spans = formatRelatedChipsHtml(related_topics);
		return `<p><strong>Related:</strong> <i>${related_spans}</i></p>`;
	}

	/**
	 * Generates the HTML string for suggested follow-up prompts.
	 * @param {Array<string>} suggestions - An array of suggested prompt strings.
	 * @returns {string} The HTML string for suggested prompts.
	 */
	_get_suggestions_html(suggestions) {
		const suggestion_spans = formatSuggestedChipsHtml(suggestions);
		return `<p><strong>Suggestions:</strong> <i>${suggestion_spans}</i></p>`;
	}

	/**
	 * 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.
	 */
	format_cost_summary(data) {
		if (!data || !data.costs || !data.tokens) {
			return '';
		}
		const total_cost = (data.costs.total !== undefined && data.costs.total !== null) ? Number(data.costs.total).toFixed(6) : '0.000000';
		const op = data.costs.op !== undefined ? Number(data.costs.op).toFixed(6) : '?';
		const features_part = (data.costs.special_features && data.costs.special_features > 0) ? `, features $${Number(data.costs.special_features).toFixed(3)}` : '';
		const model_config = Api.MODELS[data.model];
		const model_name = model_config ? `${model_config.name} (${model_config.description})` : (data.model || 'Unknown');
		let tools_part = '';
		if (data.tools) {
			if (Array.isArray(data.tools) && data.tools.length > 0) {
				const toolList = data.tools.map(t => typeof t === 'object' && t.name ? `${t.name} (${t.count || 1})` : `${t}`).join(', ');
				if (toolList) {
					tools_part = ` Tools: ${toolList}`;
				}
			} else if (typeof data.tools === 'object' && Object.keys(data.tools).length > 0) {
				const toolList = Object.entries(data.tools).map(([name, count]) => `${name} (${count})`).join(', ');
				if (toolList) {
					tools_part = ` Tools: ${toolList}`;
				}
			}
		}
		return `<p style="opacity: 0.5;"><strong>${model_name}:</strong> Input ${data.tokens.prompt || 0}, replied ${data.tokens.reply || 0}, thought ${data.tokens.thought || 0}, totalling ${data.tokens.total || 0}${features_part}, costing \u2248 $${total_cost} (op: $${op}).${tools_part}</p>`;
	}

	/**
	 * 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.
	 */
	format_history_item(data, index = 0, conversation_settings = {}) {
		if (!data) return '';
		let html = '';

		html += this.format_divider(index);
		html += this._get_history_item_header_html(data.query || '', data.chain, index);
		html += this.format_divider();

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

		if (data.pending) {
			return html;
		}

		let response_html = this.format_content_block(data.content);
		response_html += this.format_annotations_block(data.annotations);

		html += `<div class="div-response-content" data-index="${index}">${response_html}</div>`;
		html += this.format_suggestions(data, conversation_settings);
		html += this.format_cost_summary(data);

		return html;
	}

	/**
	 * 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.
	 */
	_get_history_item_header_html(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>
		`;
	}

	//endregion

}

export default Conversation;
🌐
conversation.js ×
Type: Web, text/plain
16.61 Kilobytes
Last Modified 2026-09-04 14:23:38
⬇ Download File