-
🌐 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⬇
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 ? '🔊' : '🔇');
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
Type: Web, text/plain
19.82 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File