0 directories, 46 files

util

Home / tinai / util
import { build_thinking_block, escapeHtml, highlightCodeBlocks, formatThinkingLineHtml, typesetMathjax } from './format-utils.js';
import { strip_tags } from './dom-utils.js';
import { to_speech_text } from './speech-text.js';
import { showOverlay, hideOverlay } from './overlay-utils.js';
import { speak_text, stop_playback } from './read-aloud.js';
import {
	populateUtilityModelDropdown,
	calculateUtilityCost,
	getSearchUseCost,
	formatInstantAnswerCost
} from './settings-utils.js';
import { formatCostSummary, formatAnnotationsBlock } from './conversation-format-utils.js';
import { attach_code_expand_buttons } from './code-overlay.js';
import { handleInteractiveRequest } from './interactive-tools.js';
import { append_following_bottom, content_of, init_simplebar, scroller_of } from './scrollbars.js';
import Api from '../api.js';

/** @typedef {import('../app.js').default} App */

/**
 * Manages the Instant Answer overlay: model selection, search toggle, cost display, and request flow.
 */
class InstantAnswer {

	/**
	 * @param {App} app - The main application instance.
	 */
	constructor(app) {
		this.app = app;
		this.speaking = false;
		this.speech_text = null;
	}

	/**
	 * Shows the Instant Answer modal overlay, populating models and cost estimation.
	 */
	show_instant_answer() {
		const config = this.app.storage.get_app_config();
		const appUtilityModel = this.app.select_utility_model?.value || (config && config[this.app.storage.KEY_CONFIG_UTILITY_MODEL]) || 'gemini-3.5-fl';
		const instantAnswerModel = config[this.app.storage.KEY_CONFIG_INSTANT_ANSWER_MODEL] || appUtilityModel;
		if (this.app.select_instant_answer_model) {
			populateUtilityModelDropdown(this.app.select_instant_answer_model, Api.UTILITY_MODELS, instantAnswerModel);
			this.app.select_instant_answer_model.value = instantAnswerModel;
		}
		this.update_instant_answer_search_button();
		this.update_instant_answer_auto_read_button();
		this.update_instant_answer_play_button();
		this.update_instant_answer_model_cost_display();
		this.update_instant_answer_placeholder();
		this.resize_instant_answer_textarea();
		showOverlay(this.app.div_instant_answer_overlay);
		this.resize_instant_answer_textarea();
		setTimeout(() => {
			if (this.app.instant_answer_query) {
				this.resize_instant_answer_textarea();
				this.app.instant_answer_query.focus();
			}
		}, 50);
	}

	/**
	 * Hides the Instant Answer modal overlay and clears both the query and response.
	 */
	hide_instant_answer() {
		if (this.speaking) {
			stop_playback();
		}
		this.speech_text = null;
		if (this.app.instant_answer_query) {
			this.app.instant_answer_query.value = '';
			this.app.instant_answer_query.style.height = '';
		}
		if (this.app.div_instant_answer_response) {
			this.app.div_instant_answer_response.innerHTML = '';
		}
		if (this.app.div_instant_answer_cost) {
			this.app.div_instant_answer_cost.innerHTML = '';
			this.app.div_instant_answer_cost.style.display = 'none';
		}
		if (this.app.div_instant_answer_response_container) {
			this.app.div_instant_answer_response_container.style.display = 'none';
		}
		if (this.app.div_instant_answer_status) {
			this.app.div_instant_answer_status.style.display = 'none';
			this.app.div_instant_answer_status.textContent = '';
		}
		this.hide_instant_answer_placeholder();
		if (this.app.btn_instant_answer_send) {
			this.app.btn_instant_answer_send.disabled = false;
			this.app.btn_instant_answer_send.textContent = 'Send';
		}
		if (this.app.div_instant_answer_overlay) {
			hideOverlay(this.app.div_instant_answer_overlay);
		}
	}

	/**
	 * Adjusts the height of the instant answer query textarea to match its content.
	 */
	resize_instant_answer_textarea() {
		if (!this.app.instant_answer_query) return;
		this.app.instant_answer_query.style.height = 'auto';
		if (this.app.instant_answer_query.scrollHeight > 0) {
			this.app.instant_answer_query.style.height = this.app.instant_answer_query.scrollHeight + 'px';
		}
	}

	/**
	 * Toggles the Instant Answer web search setting and persists it.
	 */
	toggle_instant_answer_search() {
		const newState = !this.get_instant_answer_search_state();
		this.app.storage.update_app_config(this.app.storage.KEY_CONFIG_INSTANT_ANSWER_SEARCH, newState);
		this.apply_instant_answer_search_state(newState);
		this.update_instant_answer_model_cost_display();
	}

	/**
	 * Returns whether web search is enabled for Instant Answer.
	 * @returns {boolean}
	 */
	get_instant_answer_search_state() {
		return this.app.btn_instant_answer_search?.dataset.search === 'on';
	}

	/**
	 * Shows the search toggle and applies the persisted state.
	 */
	update_instant_answer_search_button() {
		if (!this.app.btn_instant_answer_search) return;
		this.app.btn_instant_answer_search.style.display = '';
		const enabled = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_INSTANT_ANSWER_SEARCH] || false;
		this.apply_instant_answer_search_state(enabled);
	}

	/**
	 * Applies the search on/off state to the toggle button label.
	 * @param {boolean} enabled - Whether search is enabled.
	 */
	apply_instant_answer_search_state(enabled) {
		if (this.app.btn_instant_answer_search) {
			this.app.btn_instant_answer_search.dataset.search = enabled ? 'on' : 'off';
			const stateColor = enabled ? 'var(--TX-status-success)' : 'var(--FG-muted)';
			this.app.btn_instant_answer_search.innerHTML = `Search: <strong style="color: ${stateColor};">${enabled ? 'ON' : 'OFF'}</strong>`;
		}
	}

	/**
	 * Returns whether responses are automatically read aloud. Independent of
	 * the conversation read-aloud settings and persisted for this dialog only.
	 * @returns {boolean}
	 */
	get_instant_answer_auto_read() {
		return this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_INSTANT_ANSWER_AUTO_READ] || false;
	}

	/**
	 * Toggles the automatic read-aloud setting and persists it.
	 */
	toggle_instant_answer_auto_read() {
		this.app.storage.update_app_config(this.app.storage.KEY_CONFIG_INSTANT_ANSWER_AUTO_READ, !this.get_instant_answer_auto_read());
		this.update_instant_answer_auto_read_button();
	}

	/**
	 * Handles clicks on the auto read button: stops the playback while it is
	 * playing without changing the setting, otherwise it toggles the setting.
	 */
	handle_instant_answer_auto_read_click() {
		if (this.speaking) {
			stop_playback();
			return;
		}
		this.toggle_instant_answer_auto_read();
		this.update_instant_answer_placeholder();
	}

	/**
	 * Updates the auto read button icon: the playback-bar pause glyph while
	 * playback runs, otherwise a speaker, muted when the setting is off.
	 */
	update_instant_answer_auto_read_button() {
		if (!this.app.btn_instant_answer_auto_read) return;
		const enabled = this.get_instant_answer_auto_read();
		this.app.btn_instant_answer_auto_read.innerHTML = this.speaking ? '\u23F8' : (enabled ? '&#128266;' : '&#128263;');
		this.app.btn_instant_answer_auto_read.title = this.speaking ? 'Stop playback' : 'Auto Read: ' + (enabled ? 'ON' : 'OFF');
	}

	/**
	 * Shows the read-aloud status message centered in the response area while
	 * no answer is displayed, and keeps its text in sync with the auto read
	 * setting. Does nothing once an answer or thinking indicator is shown.
	 */
	update_instant_answer_placeholder() {
		const placeholder = this.app.div_instant_answer_placeholder;
		const response = this.app.div_instant_answer_response;
		const container = this.app.div_instant_answer_response_container;
		if (!placeholder || !response || !container) return;
		if (container.style.display !== 'none' && response.innerHTML.trim() !== '') return;
		this.set_answer_header_visible(false);
		placeholder.innerHTML = this.get_instant_answer_auto_read()
			? '<strong>Auto-Read is ON.</strong><br>Answers will play aloud automatically.'
			: '<strong>Auto-Read is OFF.</strong><br>Click the Play button to read the answer.';
		placeholder.style.display = 'flex';
		container.style.display = 'block';
	}

	/**
	 * Shows or hides the answer header row (clear, play, and the Answer label),
	 * which is only displayed while an answer is on screen.
	 * @param {boolean} visible - Whether an answer is being displayed.
	 */
	set_answer_header_visible(visible) {
		if (this.app.div_instant_answer_answer_header) {
			this.app.div_instant_answer_answer_header.style.display = visible ? 'flex' : 'none';
		}
	}

	/**
	 * Clears the displayed answer and its playback, returning the dialog to
	 * the read-aloud status placeholder.
	 */
	clear_answer() {
		if (this.speaking) {
			stop_playback();
		}
		this.speech_text = null;
		if (this.app.div_instant_answer_response) {
			this.app.div_instant_answer_response.innerHTML = '';
		}
		if (this.app.div_instant_answer_cost) {
			this.app.div_instant_answer_cost.innerHTML = '';
			this.app.div_instant_answer_cost.style.display = 'none';
		}
		if (this.app.div_instant_answer_status) {
			this.app.div_instant_answer_status.style.display = 'none';
			this.app.div_instant_answer_status.textContent = '';
		}
		this.update_instant_answer_placeholder();
	}

	/**
	 * Hides the read-aloud status message.
	 */
	hide_instant_answer_placeholder() {
		if (this.app.div_instant_answer_placeholder) {
			this.app.div_instant_answer_placeholder.style.display = 'none';
		}
	}

	/**
	 * Updates the answer play/pause button to reflect the current playback
	 * state: a pause glyph while it plays, a play glyph when idle.
	 */
	update_instant_answer_play_button() {
		if (!this.app.btn_instant_answer_play) return;
		this.app.btn_instant_answer_play.textContent = this.speaking ? '\u23F8' : '\u25B6';
		this.app.btn_instant_answer_play.title = this.speaking ? 'Stop playback' : 'Read answer aloud';
	}

	/**
	 * Refreshes every playback-related control of the dialog.
	 */
	sync_playback_buttons() {
		this.update_instant_answer_auto_read_button();
		this.update_instant_answer_play_button();
	}

	/**
	 * Reads the current answer aloud, replacing a running playback.
	 */
	play_answer_aloud() {
		if (!this.speech_text) return;
		void speak_text(this.speech_text, this._tts_settings(), () => this.sync_playback_buttons(), () => {
			this.speaking = false;
			this.sync_playback_buttons();
		});
		this.speaking = true;
		this.sync_playback_buttons();
	}

	/**
	 * Handles clicks on the answer play/pause button: stops the playback while
	 * it runs, otherwise reads the answer aloud on demand.
	 */
	toggle_answer_playback() {
		if (this.speaking) {
			stop_playback();
			return;
		}
		this.play_answer_aloud();
	}

	/**
	 * Returns the TTS settings configured for read-aloud.
	 * @returns {Object} The settings { tts_model, voice }.
	 */
	_tts_settings() {
		const config = this.app.storage.get_app_config();
		return {
			tts_model: config[this.app.storage.KEY_CONFIG_TTS_MODEL] || '',
			voice: config[this.app.storage.KEY_CONFIG_TTS_VOICE] || ''
		};
	}

	/**
	 * Prepares the spoken form of a returned answer (tables, rendered
	 * equations, and diagrams converted to a spoken form) and reads it aloud
	 * when the auto read setting is on. A running playback is replaced.
	 * @param {string} explanation - The answer HTML.
	 */
	maybe_auto_read_answer(explanation) {
		const body = this.app.div_instant_answer_response?.querySelector('.instant-answer-body');
		this.speech_text = to_speech_text(body) || strip_tags(explanation || '').trim();
		this.set_answer_header_visible(true);
		this.update_instant_answer_play_button();
		if (!this.get_instant_answer_auto_read() || !this.speech_text) return;
		this.play_answer_aloud();
	}

	/**
	 * Updates the estimated cost display for the selected instant answer model.
	 */
	update_instant_answer_model_cost_display() {
		if (!this.app.div_instant_answer_model_cost) return;
		const utilityKey = this.app.select_instant_answer_model?.value || 'gemini-3.5-fl';
		const utilityConfig = Api.UTILITY_MODELS?.[utilityKey];
		const modelId = utilityConfig?.model || utilityKey;
		const thinking = utilityConfig?.thinking || null;
		const costInfo = calculateUtilityCost(modelId, thinking);
		let total = costInfo.total * 8;
		if (this.get_instant_answer_search_state()) {
			total += 6 * getSearchUseCost(modelId);
		}
		const formattedCost = formatInstantAnswerCost(total);
		this.app.div_instant_answer_model_cost.innerHTML = `<span class="span-utility-cost-label span-cost-label-full">Estimated Cost:</span><span class="span-utility-cost-label span-cost-label-short">Est. Cost:</span><span class="span-utility-cost-val">${formattedCost}</span>`;
	}

	/**
	 * Handles keydown events in the instant answer query textarea.
	 * @param {KeyboardEvent} e - The keyboard event.
	 */
	handle_instant_answer_keydown(e) {
		if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
			e.preventDefault();
			if (!this.app.btn_instant_answer_send || !this.app.btn_instant_answer_send.disabled) {
				void this.send_instant_answer();
			}
		}
	}

	/**
	 * Sends the instant answer query to the backend ApiUtility service with live thinking stream support.
	 * @returns {Promise<void>}
	 */
	async send_instant_answer() {
		if (!this.app.instant_answer_query) return;
		const query = this.app.instant_answer_query.value.trim();
		if (!query) {
			if (this.app.div_instant_answer_status) {
				this.app.div_instant_answer_status.textContent = 'Please enter a query.';
				this.app.div_instant_answer_status.className = 'status-msg status-error';
				this.app.div_instant_answer_status.style.display = 'block';
			}
			return;
		}

		const selectedModel = this.app.select_instant_answer_model ? this.app.select_instant_answer_model.value : 'gemini-3.5-fl';

		// Set UI loading state
		if (this.app.btn_instant_answer_send) {
			this.app.btn_instant_answer_send.disabled = true;
			this.app.btn_instant_answer_send.textContent = 'Sending...';
		}
		if (this.app.div_instant_answer_status) {
			this.app.div_instant_answer_status.style.display = 'none';
			this.app.div_instant_answer_status.textContent = '';
		}

		// Reset previous response while loading new one
		this.hide_instant_answer_placeholder();
		this.set_answer_header_visible(false);
		const streamedThoughts = [];
		if (this.app.div_instant_answer_response) {
			this.app.div_instant_answer_response.innerHTML = '<em>Thinking...</em>';
		}
		if (this.app.div_instant_answer_cost) {
			this.app.div_instant_answer_cost.innerHTML = '';
			this.app.div_instant_answer_cost.style.display = 'none';
		}
		if (this.app.div_instant_answer_response_container) {
			this.app.div_instant_answer_response_container.style.display = 'block';
		}

		let thoughtsContentDiv = null;

		const onThinking = (thoughtText) => {
			if (!thoughtText) return;
			streamedThoughts.push(thoughtText);
			if (this.app.div_instant_answer_response) {
				if (!thoughtsContentDiv || !this.app.div_instant_answer_response.contains(thoughtsContentDiv)) {
					this.app.div_instant_answer_response.innerHTML = build_thinking_block('Thinking...');
					thoughtsContentDiv = this.app.div_instant_answer_response.querySelector('.instant-answer-thoughts-content');
					// the streaming block scrolls itself, so it carries a themed
					// scrollbar like the rest of the app
					init_simplebar(thoughtsContentDiv);
				}
				if (thoughtsContentDiv) {
					const lines = String(thoughtText).split("\n").filter(l => l.length > 0);
					const htmlChunks = lines.map(l => formatThinkingLineHtml(l)).join('');
					append_following_bottom(scroller_of(thoughtsContentDiv), () => content_of(thoughtsContentDiv).insertAdjacentHTML('beforeend', htmlChunks));
					// Dispatch interactive requests (e.g. browser fetch tools) so
					// they execute instead of timing out on the server.
					lines.forEach(line => handleInteractiveRequest(line, null, thoughtsContentDiv, this.app));
				}
			}
		};

		try {
			const response = await this.app.api.evaluate_utility(query, 'instant_answer', selectedModel, onThinking, null, {
				search: this.get_instant_answer_search_state()
			}, this.get_instant_answer_auto_read());
			const finalThoughts = (Array.isArray(response?.thinking) && response.thinking.length > 0) ? response.thinking : streamedThoughts;
			if (response && response.error) {
				if (this.app.div_instant_answer_status) {
					this.app.div_instant_answer_status.textContent = response.error;
					this.app.div_instant_answer_status.className = 'status-msg status-error';
					this.app.div_instant_answer_status.style.display = 'block';
				}
				if (this.app.div_instant_answer_response) {
					let html = '';
					if (finalThoughts.length > 0) {
						const thoughtsFormatted = finalThoughts.map(t => formatThinkingLineHtml(t)).join('');
						html += build_thinking_block(`Thought Process (${finalThoughts.length} lines)`, thoughtsFormatted, false);
					}
					html += `<span class="span-status-error">${escapeHtml(response.error)}</span>`;
					this.app.div_instant_answer_response.innerHTML = html;
					this.#init_thoughts_scrollbar();
					this.set_answer_header_visible(true);
				}
			} else {
				const explanation = response?.explanation || (typeof response === 'string' ? response : 'No answer returned.');
				let html = '';
				if (finalThoughts.length > 0) {
					const thoughtsFormatted = finalThoughts.map(t => formatThinkingLineHtml(t)).join('');
					html += `<details class="instant-answer-thoughts"><summary>Thought Process (${finalThoughts.length} lines)</summary><div class="instant-answer-thoughts-content">${thoughtsFormatted}</div></details>`;
				}
				html += `<div class="instant-answer-body sb-x" data-simplebar data-simplebar-auto-hide="false">${explanation}</div>`;
				if (this.get_instant_answer_search_state()) {
					html += formatAnnotationsBlock(response?.annotations);
				}
				if (this.app.div_instant_answer_response) {
					this.app.div_instant_answer_response.innerHTML = html;
					this.#init_thoughts_scrollbar();
					highlightCodeBlocks(this.app.div_instant_answer_response);
					attach_code_expand_buttons(this.app.div_instant_answer_response);
					await this.app._render_mermaid_in_container(this.app.div_instant_answer_response);
					await typesetMathjax(this.app.div_instant_answer_response);
				}
				if (this.app.div_instant_answer_cost) {
					if (response && typeof response === 'object') {
						const costHtml = formatCostSummary(response);
						this.app.div_instant_answer_cost.innerHTML = costHtml;
						this.app.div_instant_answer_cost.style.display = costHtml ? 'block' : 'none';
					} else {
						this.app.div_instant_answer_cost.innerHTML = '';
						this.app.div_instant_answer_cost.style.display = 'none';
					}
				}
				this.maybe_auto_read_answer(explanation);
			}
		} catch (error) {
			console.error('Instant answer request failed:', error);
			const errMsg = error?.message || error?.error || 'Failed to fetch instant answer.';
			if (this.app.div_instant_answer_status) {
				this.app.div_instant_answer_status.textContent = errMsg;
				this.app.div_instant_answer_status.className = 'status-msg status-error';
				this.app.div_instant_answer_status.style.display = 'block';
			}
			if (this.app.div_instant_answer_response) {
				let html = '';
				if (streamedThoughts.length > 0) {
					const thoughtsFormatted = streamedThoughts.map(t => formatThinkingLineHtml(t)).join('');
					html += build_thinking_block(`Thought Process (${streamedThoughts.length} lines)`, thoughtsFormatted, false);
				}
				html += `<span class="span-status-error">${escapeHtml(errMsg)}</span>`;
				this.app.div_instant_answer_response.innerHTML = html;
				this.#init_thoughts_scrollbar();
				this.set_answer_header_visible(true);
			}
			if (this.app.div_instant_answer_cost) {
				this.app.div_instant_answer_cost.innerHTML = '';
				this.app.div_instant_answer_cost.style.display = 'none';
			}
		} finally {
			if (this.app.btn_instant_answer_send) {
				this.app.btn_instant_answer_send.disabled = false;
				this.app.btn_instant_answer_send.textContent = 'Send';
			}
		}
	}

	/**
	 * Initializes the themed scrollbar on the response's thoughts block after
	 * the final render replaces the streaming block, so its vertical track
	 * stays visible like everywhere else.
	 */
	#init_thoughts_scrollbar() {
		const block = this.app.div_instant_answer_response?.querySelector('.instant-answer-thoughts-content');
		if (block) {
			init_simplebar(block);
		}
	}
}

export default InstantAnswer;
🌐
instant-answer.js ×
Type: Web, text/plain
19.82 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File