0 directories, 46 files

util

Home / tinai / util
import { formatThinkingLineHtml } from './format-utils.js';
import { getEl, setElementDisplay, render_token_fade, strip_tags } from './dom-utils.js';
import { append_following_bottom } from './scrollbars.js';
import { customAlert } from './confirm-dialog.js';
import { handleInteractiveRequest } from './interactive-tools.js';

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

/**
 * Chat API response. Most fields are dynamic and may be absent depending on the provider and request.
 * @typedef {Object} ChatResponse
 * @property {string} [title] - Updated conversation title.
 * @property {string} [conversationTitle] - Updated conversation title (alternative field name).
 * @property {string} [summary] - Updated conversation summary.
 * @property {string} [conversationSummary] - Updated conversation summary (alternative field name).
 * @property {string} [proposed_query] - Follow-up query proposed by the model.
 * @property {Array} [topics] - Conversation topics.
 * @property {Array} [considerations] - Conversation considerations.
 * @property {string|string[]} [thinking] - Thought lines or newline-separated thoughts.
 * @property {boolean} [chain] - Whether the response is part of a chained query.
 * @property {string} [query] - The original user prompt query (assigned when storing the response).
 */

/**
 * Manages the request/response lifecycle: sending prompts, progress UI, background keep-alive,
 * streaming thinking, and success/failure handling.
 */
class ResponsePipeline {

	#wakeLock = null;
	#audioCtx = null;
	#document_title_timer = null;
	#scratchpad_title_timer = null;
	#ranking_title_timer = null;
	#sticky_title_timer = null;

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

	/**
	 * Updates the loading indicator for a conversation or all conversations across the UI.
	 * @param {string|null} [guid=null] - Conversation GUID.
	 * @param {boolean|null} [isLoading=null] - Explicit loading state.
	 */
	update_conversation_loading_indicators(guid = null, isLoading = null) {
		if (guid) {
			const active = (isLoading !== null) ? Boolean(isLoading) : this.app.api.is_request_active(guid);
			this.app.content_list?.set_content_loading?.(guid, active);
			this.app.scratchpad_index?.set_scratchpad_loading?.(guid, active);
			this.app.tools_index?.set_tool_loading?.(guid, active);
		} else {
			const index = this.app.storage.get_app_index() || [];
			index.forEach(item => {
				const g = item[this.app.storage.KEY_INDEX_GUID];
				if (g) {
					const active = this.app.api.is_request_active(g);
					this.app.content_list?.set_content_loading?.(g, active);
					this.app.scratchpad_index?.set_scratchpad_loading?.(g, active);
					this.app.tools_index?.set_tool_loading?.(g, active);
				}
			});
		}
		const anyScratchpadLoading = this.app._is_any_scratchpad_loading();
		this.app.content_list?.set_scratchpad_root_loading?.(anyScratchpadLoading);
		const anyToolLoading = this.app._is_any_tool_loading();
		this.app.content_list?.set_tools_root_loading?.(anyToolLoading);
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		this.app.chat_renderer?.set_header_loading?.(!!selected_guid && this.app.api.is_request_active(selected_guid));
	}

	/**
	 * Whether the experimental Show Chunks feature is enabled, requiring the
	 * master experimental features switch to be on as well.
	 * @returns {boolean}
	 */
	show_chunks_enabled() {
		const config = this.app.storage.get_app_config();
		return !!(config[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] && config[this.app.storage.KEY_CONFIG_SHOW_CHUNKS]);
	}

	/**
	 * Computes the total byte count of thinking data for the pending turn of a conversation from storage.
	 * @param {string} guid - Conversation GUID.
	 * @returns {number} Sum of bytes in thinking content.
	 */
	get_pending_thinking_bytes(guid) {
		if (!guid) return 0;
		const conversation = this.app.storage.get_conversation(guid);
		const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
		if (!Array.isArray(history) || history.length === 0) return 0;

		const pendingItem = history.find(item => item && item.pending) || history[history.length - 1];
		if (!pendingItem || !pendingItem.pending || !pendingItem.thinking) return 0;

		if (Array.isArray(pendingItem.thinking)) {
			return pendingItem.thinking.reduce((total, line) => {
				if (typeof line === 'string') {
					return total + (new TextEncoder().encode(line).length + 1);
				}
				return total;
			}, 0);
		} else if (typeof pendingItem.thinking === 'string') {
			return new TextEncoder().encode(pendingItem.thinking).length;
		}
		return 0;
	}

	/**
	 * Prepares the payload and initiates sending a prompt to the API.
	 */
	send() {
		if (!this.app.validate_prompt()) {
			return;
		}

		if (this.app.is_document_active()) {
			this.send_document_prompt(this.app.prompt.value);
			return;
		}

		if (this.app.is_ranking_active()) {
			this.send_ranking_prompt(this.app.prompt.value);
			return;
		}

		if (this.app.is_sticky_active()) {
			this.send_sticky_notes_prompt(this.app.prompt.value);
			return;
		}

		if (this.app.is_scratchpad_active()) {
			this.send_scratchpad_prompt(this.app.prompt.value);
			return;
		}

		if (this.app.btn_send) {
			this.app.btn_send.disabled = true;
			this.app.btn_send.textContent = '...';
		}

		const query = this.app.prompt.value;
		const guid = this.#resolve_prompt_guid(query, this.app.storage.CONVERSATION_TYPE_CHAT, false);

		const context = [];
		if (guid) {
			const conversation = this.app.storage.get_conversation(guid);
			let conversationHistory = conversation[this.app.storage.KEY_CONVERSATION_HISTORY] || [];

			const wasEmpty = (conversationHistory.filter(item => !item.pending).length === 0);
			conversationHistory = conversationHistory.filter(item => !item.pending && this.app._has_valid_content(item));
			conversationHistory.push({
				query: query,
				pending: true,
				thinking: []
			});

			conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = conversationHistory;
			this.app.storage.save_conversation(guid, conversation);

			if (wasEmpty && window.innerWidth > this.app.BREAKPOINT_MOBILE) {
				if (!this.app.state_v_open) {
					this.app.state_i_open = true;
					this.app.apply_panels_layout();
				}
			}

			this.app.on_conversation_updated();

			const history = conversation[this.app.storage.KEY_CONVERSATION_HISTORY];
			if (history && history.length > 1) {
				const max_full_values = 3;
				const max_summaries = 6;
				let fullValueCount = 0;
				let summaryCount = 0;
				let chainBroken = false;

				for (let i = history.length - 2; i >= 0; i--) {
					const item = history[i];
					if (item.pending) continue;
					const prompt = item.query;
					let reply = '';

					if (!chainBroken && fullValueCount < max_full_values) {
						reply = (item.content || []).map(c => {
							if (c.type === 'table' && c['table-rows']) {
								return c['table-rows'].map(row => row.join(' | ')).join('\n');
							}
							return c.value || '';
						}).join('\n ');
						fullValueCount++;
					} else if (summaryCount < max_summaries) {
						reply = item['summary'] || '';
						summaryCount++;
					} else {
						break;
					}

					context.unshift({
						prompt: prompt,
						reply: reply
					});

					if (item.chain === false) {
						chainBroken = true;
					}
				}
			}
		}

		const selected_conversation = this.app.storage.get_selected_conversation();
		const app_defaults = this.app.storage.get_app_defaults();
		const verbosity = selected_conversation?.[this.app.storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
		const { family, model_version, thinking_level, model_key } = this.resolve_conversation_model();
		const meta_context = this.#build_meta_context(selected_conversation, model_version);

		this.show_progress_ui(guid);
		void this.start_background_keep_alive();
		this.update_conversation_loading_indicators(guid, true);

		const payload = this.app.api.format_data(
			query,
			context,
			verbosity,
			model_key,
			meta_context,
			false,
			{ family, model: model_version, thinking: thinking_level }
		);

		payload.max_tool_use = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_MAX_TOOL_USE] || 20;
		payload.allow_tool_requests = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_ALLOW_TOOL_REQUESTS] || false;

		void this.app.api.post(
			payload,
			(response, targetGuid) => {
				this.send_success(response, query, targetGuid || guid);
			},
			(response, targetGuid) => {
				this.send_failure(response, targetGuid || guid);
			},
			'chat',
			null,
			(bytes, targetGuid) => this.update_progress(bytes, targetGuid || guid),
			guid
		);
	}

	/**
	 * Resolves the selected conversation's model selection against the app defaults.
	 * @returns {{family: string, model_version: string, thinking_level: string, model_key: string}} The resolved model selection.
	 */
	resolve_conversation_model() {
		const selected_conversation = this.app.storage.get_selected_conversation();
		const app_defaults = this.app.storage.get_app_defaults();
		const family = selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
		const model_version = selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_VERSION] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
		const thinking_level = (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_THINKING_LEVEL] !== undefined)
			? selected_conversation[this.app.storage.KEY_CONVERSATION_THINKING_LEVEL]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL] ?? 'MINIMAL');
		const has_explicit_model = !!(selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_FAMILY] || selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_VERSION]);
		let model_key = selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL];
		if (model_key === undefined || model_key === null || model_key === '') {
			model_key = has_explicit_model ? (model_version || '') : (app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL] || '');
		}
		return { family, model_version, thinking_level, model_key };
	}

	/**
	 * Returns whether web search is enabled on the selected conversation.
	 * @returns {boolean}
	 */
	conversation_search_enabled() {
		const selected_conversation = this.app.storage.get_selected_conversation();
		return !!selected_conversation?.[this.app.storage.KEY_CONVERSATION_SEARCH];
	}

	/**
	 * Sends the prompt to the documents endpoint for the active document,
	 * streaming reasoning into the document thinking panel. Like chat and
	 * scratchpad requests, the prompt input is replaced by the progress
	 * display (with a working Stop button) while the request runs.
	 * @param {string} prompt - The user's prompt.
	 */
	send_document_prompt(prompt) {
		const trimmed = (prompt || '').trim();
		if (!trimmed || !this.app.document) return;
		const { model_version, model_key, thinking_level } = this.resolve_conversation_model();
		const meta_context = {};
		if (this.conversation_search_enabled()) {
			meta_context.search = true;
		}
		if (this.app.btn_send) {
			this.app.btn_send.disabled = true;
			this.app.btn_send.textContent = '...';
		}
		if (this.app.prompt) {
			this.app.prompt.value = '';
			this.app.resize_prompt_textarea();
		}
		const guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const asked = this.app.document.ask(trimmed, {
			model_key: model_key,
			thinking: thinking_level,
			meta_context: meta_context,
			app: this.app,
			guid: guid,
			on_done: () => {
				this.hide_progress_ui();
				this.#restore_document_prompt_ui();
			},
			on_error: () => {
				this.hide_progress_ui();
				this.#restore_document_prompt_ui(prompt);
			}
		});
		if (asked === false) {
			// a request for this document is already in flight; restore the UI
			this.#restore_document_prompt_ui(prompt);
			return;
		}
		this.show_progress_ui(guid);
	}

	/**
	 * Sends the prompt to the rankings endpoint for the active ranking
	 * conversation's choice list, streaming reasoning into the ranking
	 * thinking panel. Like document requests, the prompt input is replaced by
	 * the progress display (with a working Stop button) while the request runs.
	 * @param {string} prompt - The user's prompt.
	 */
	send_ranking_prompt(prompt) {
		const trimmed = (prompt || '').trim();
		if (!trimmed || !this.app.choices) return;
		const { model_version, model_key, thinking_level } = this.resolve_conversation_model();
		const meta_context = {};
		if (this.conversation_search_enabled()) {
			meta_context.search = true;
		}
		if (this.app.btn_send) {
			this.app.btn_send.disabled = true;
			this.app.btn_send.textContent = '...';
		}
		if (this.app.prompt) {
			this.app.prompt.value = '';
			this.app.resize_prompt_textarea();
		}
		const guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const asked = this.app.choices.ask(trimmed, {
			model_key: model_key,
			thinking: thinking_level,
			meta_context: meta_context,
			app: this.app,
			guid: guid,
			on_done: () => {
				this.hide_progress_ui();
				this.#restore_document_prompt_ui();
			},
			on_error: () => {
				this.hide_progress_ui();
				this.#restore_document_prompt_ui(prompt);
			}
		});
		if (asked === false) {
			// a request for this choice list is already in flight; restore the UI
			this.#restore_document_prompt_ui(prompt);
			return;
		}
		this.show_progress_ui(guid);
	}

	/**
	 * Sends the prompt to the stickynotes endpoint for the active sticky
	 * notes conversation's board, streaming reasoning into the notes thinking
	 * panel. Like ranking requests, the prompt input is replaced by the
	 * progress display (with a working Stop button) while the request runs.
	 * @param {string} prompt - The user's prompt.
	 */
	send_sticky_notes_prompt(prompt) {
		const trimmed = (prompt || '').trim();
		if (!trimmed || !this.app.sticky) return;
		const { model_version, model_key, thinking_level } = this.resolve_conversation_model();
		const meta_context = {};
		if (this.conversation_search_enabled()) {
			meta_context.search = true;
		}
		if (this.app.btn_send) {
			this.app.btn_send.disabled = true;
			this.app.btn_send.textContent = '...';
		}
		if (this.app.prompt) {
			this.app.prompt.value = '';
			this.app.resize_prompt_textarea();
		}
		const guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const asked = this.app.sticky.ask(trimmed, {
			model_key: model_key,
			thinking: thinking_level,
			meta_context: meta_context,
			app: this.app,
			guid: guid,
			on_done: () => {
				this.hide_progress_ui();
				this.#restore_document_prompt_ui();
			},
			on_error: () => {
				this.hide_progress_ui();
				this.#restore_document_prompt_ui(prompt);
			}
		});
		if (asked === false) {
			// a request for this board is already in flight; restore the UI
			this.#restore_document_prompt_ui(prompt);
			return;
		}
		this.show_progress_ui(guid);
	}

	/**
	 * Runs the Rank button: sends the prompt and the current choice items to
	 * the ranking endpoint and records the result as a new block on the
	 * conversation's RANKINGS history, then flips to the Rankings tab. The
	 * choices themselves are never modified.
	 */
	send_rank_action() {
		const config = this.app.storage.get_app_config();
		const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_RANKING) return;
		if (this.app.api.is_request_active(guid)) return;

		const choices = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
		if (choices.length < 2) return;

		const prompt = (this.app.prompt?.value || '').trim();
		if (!prompt) return;

		const { model_version, model_key, thinking_level } = this.resolve_conversation_model();
		const meta_context = {};
		if (this.conversation_search_enabled()) {
			meta_context.search = true;
		}
		const show_suggested = (conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
			: (this.app.storage.get_app_defaults()?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
		const show_related = (conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
			: (this.app.storage.get_app_defaults()?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);

		if (this.app.btn_rank) {
			this.app.btn_rank.disabled = true;
			this.app.btn_rank.textContent = '...';
		}
		if (this.app.btn_send) {
			this.app.btn_send.disabled = true;
			this.app.btn_send.textContent = '...';
		}
		if (this.app.prompt) {
			this.app.prompt.value = '';
			this.app.resize_prompt_textarea();
		}

		// copied at send time so edits during the run cannot alter the record
		const items_at_send = choices.map(item => ({ guid: item.guid, value: item.value || '' }));

		const asked = this.app.choices.rank(prompt, {
			model_key: model_key,
			thinking: thinking_level,
			meta_context: meta_context,
			show_suggested: show_suggested,
			show_related: show_related,
			app: this.app,
			guid: guid,
			on_result: (response) => this.#store_rank_result(guid, prompt, items_at_send, response),
			on_done: () => {
				this.hide_progress_ui();
				this.#restore_document_prompt_ui();
			},
			on_error: () => {
				this.hide_progress_ui();
				this.#restore_document_prompt_ui(prompt);
			}
		});
		if (asked === false) {
			this.#restore_document_prompt_ui(prompt);
			return;
		}
		this.show_progress_ui(guid);
	}

	/**
	 * Appends a rank result block to the conversation's RANKINGS history.
	 * The full text of every ranked choice is copied at this point, so later
	 * edits to the choices never alter the recorded ranking.
	 * @param {string} guid - The conversation guid.
	 * @param {string} prompt - The ranking prompt.
	 * @param {Array<{guid: string, value: string}>} items_at_send - The choices as sent.
	 * @param {Object} response - The structured ranking response.
	 */
	#store_rank_result(guid, prompt, items_at_send, response) {
		const text_of = (item_guid) => {
			const item = items_at_send.find(i => i.guid === item_guid);
			return item ? this.#choice_to_text(item.value) : '';
		};
		const block = {
			guid: crypto.randomUUID(),
			timestamp: Date.now(),
			prompt: prompt,
			reply: String(response?.reply || ''),
			items: (Array.isArray(response?.ranked) ? response.ranked : []).map(ranked => ({
				guid: ranked.guid,
				index: ranked.index,
				score: ranked.score,
				reason: ranked.reason || '',
				text: text_of(ranked.guid)
			})),
			additional_items: Array.isArray(response?.additional_items) ? response.additional_items : [],
			suggestions: Array.isArray(response?.suggestions) ? response.suggestions : [],
			related: Array.isArray(response?.related) ? response.related : []
		};
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation) return;
		const history = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_RANKINGS]) ? conversation[this.app.storage.KEY_CONVERSATION_RANKINGS] : [];
		conversation[this.app.storage.KEY_CONVERSATION_RANKINGS] = [...history, block];
		this.app.storage.save_conversation(guid, conversation);
		this.app.sync?.request();
		if (this.app.chat_renderer) {
			this.app.chat_renderer.rankings_scroll_pending = true;
		}
		this.app.set_active_tab('rankings');
	}

	/**
	 * Converts a choice value into plain text, preserving line breaks.
	 * @param {string} value - The choice's inline html.
	 * @returns {string} The plain text.
	 */
	#choice_to_text(value) {
		return strip_tags(String(value || '').replace(/<br\s*\/?>/gi, '\n')).replace(/\n{3,}/g, '\n\n').trim();
	}

	/**
	 * Restores the document prompt UI after a document request finishes:
	 * re-enables Send, shows the prompt input again, and optionally restores
	 * the prompt text.
	 * @param {string} [prompt=''] - Prompt text to restore, or empty to leave cleared.
	 */
	#restore_document_prompt_ui(prompt = '') {
		if (this.app.btn_send) {
			this.app.btn_send.disabled = false;
			this.app.btn_send.textContent = 'Send';
		}
		setElementDisplay(getEl('div-prompt-input'), 'block');
		if (this.app.prompt) {
			this.app.prompt.value = prompt;
			this.app.resize_prompt_textarea();
			this.app.validate_prompt();
		}
	}

	/**
	 * Sends the prompt to the scratchpad endpoint: the current scratchpad
	 * state (items and table of contents) is copied into a new pending
	 * revision and the request operates on that copy, so history and undo
	 * keep working and the changes summary attaches to the revision on
	 * completion. Thinking streams through the shared chat thinking path.
	 * @param {string} query - The user's edit instruction.
	 */
	send_scratchpad_prompt(query) {
		const trimmed = (query || '').trim();
		if (!trimmed) return;

		if (this.app.btn_send) {
			this.app.btn_send.disabled = true;
			this.app.btn_send.textContent = '...';
		}

		const guid = this.#resolve_prompt_guid(trimmed, this.app.storage.CONVERSATION_TYPE_SCRATCHPAD, true);

		const conversation = this.app.storage.get_conversation(guid);
		let history = (conversation[this.app.storage.KEY_CONVERSATION_HISTORY] || []).filter(item => !item.pending);
		const previous = history[history.length - 1];
		const revision = {
			query: trimmed,
			pending: true,
			thinking: [],
			items: previous && Array.isArray(previous.items) ? JSON.parse(JSON.stringify(previous.items)) : [],
			toc: previous && Array.isArray(previous.toc) ? JSON.parse(JSON.stringify(previous.toc)) : []
		};
		history.push(revision);
		conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
		this.app.storage.save_conversation(guid, conversation);

		this.app.on_conversation_updated();

		const selected_conversation = this.app.storage.get_conversation(guid);
		const app_defaults = this.app.storage.get_app_defaults();
		const verbosity = selected_conversation?.[this.app.storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
		const { family, model_version, thinking_level, model_key } = this.resolve_conversation_model();
		const meta_context = this.#build_meta_context(selected_conversation, model_version);

		this.show_progress_ui(guid);
		void this.start_background_keep_alive();
		this.update_conversation_loading_indicators(guid, true);

		const payload = this.app.api.format_data(
			trimmed,
			[],
			verbosity,
			model_key,
			meta_context,
			false,
			{ family, model: model_version, thinking: thinking_level }
		);
		payload.items = revision.items;
		payload.toc = revision.toc;
		payload.prompt = trimmed;
		payload.max_tool_use = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_MAX_TOOL_USE] || 20;
		payload.allow_tool_requests = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_ALLOW_TOOL_REQUESTS] || false;

		void this.app.api.post(
			payload,
			(response, targetGuid) => {
				this.send_success(response, trimmed, targetGuid || guid);
			},
			(response, targetGuid) => {
				this.send_failure(response, targetGuid || guid);
			},
			'scratchpad',
			null,
			(bytes, targetGuid) => this.update_progress(bytes, targetGuid || guid),
			guid
		);
	}

	/**
	 * Resolves the conversation guid for a new prompt: creating a conversation
	 * of the default type when sending from a root view (optionally from the
	 * scratchpad sentinel), and recording the prompt draft either way.
	 * @param {string} query - The prompt text, stored as the draft.
	 * @param {string} defaultType - The conversation type for a new conversation.
	 * @param {boolean} allowSentinel - Whether the 'scratchpad' sentinel guid starts a new conversation.
	 * @returns {string} The conversation guid.
	 */
	#resolve_prompt_guid(query, defaultType, allowSentinel) {
		const config = this.app.storage.get_app_config();
		let guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (!guid || (allowSentinel && guid === 'scratchpad')) {
			guid = this.app.storage.update_app_index('', true, defaultType);
			if (this.app.prompt_drafts['']) {
				this.app.prompt_drafts[guid] = this.app.prompt_drafts[''];
				delete this.app.prompt_drafts[''];
			} else {
				this.app.prompt_drafts[guid] = query;
			}
			this.app.active_conversation_guid = guid;
			this.app.storage.update_app_config(this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, guid);
		} else {
			this.app.prompt_drafts[guid] = query;
		}
		return guid;
	}

	/**
	 * Builds the request meta context from the conversation's options and the
	 * resolved model: topics, considerations, search grounding, topic
	 * enforcement, and the safeguard/creativity flags.
	 * @param {Object|null} selected_conversation - The conversation object.
	 * @param {string} model_version - The resolved model version id.
	 * @returns {Object} The meta context payload.
	 */
	#build_meta_context(selected_conversation, model_version) {
		const app_defaults = this.app.storage.get_app_defaults();
		const meta_context = {};

		if (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_TOPICS]) {
			meta_context.topics = selected_conversation[this.app.storage.KEY_CONVERSATION_TOPICS];
		}
		if (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_CONSIDERATIONS]) {
			meta_context.considerations = selected_conversation[this.app.storage.KEY_CONVERSATION_CONSIDERATIONS];
		}
		if (this.conversation_search_enabled()) {
			meta_context.search = true;
		}

		meta_context.enforce_topics = selected_conversation?.[this.app.storage.KEY_CONVERSATION_ENFORCE_TOPICS] || app_defaults?.[this.app.storage.KEY_CONFIG_ENFORCE_TOPICS] || false;

		const disable_safeguards = (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_DISABLE_SAFEGUARDS] !== undefined)
			? selected_conversation[this.app.storage.KEY_CONVERSATION_DISABLE_SAFEGUARDS]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_DISABLE_SAFEGUARDS] || false);
		const encourage_creativity = (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_ENCOURAGE_CREATIVITY] !== undefined)
			? selected_conversation[this.app.storage.KEY_CONVERSATION_ENCOURAGE_CREATIVITY]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_ENCOURAGE_CREATIVITY] || false);

		meta_context.disable_safeguards = disable_safeguards;
		meta_context.encourage_creativity = !!(disable_safeguards && encourage_creativity);
		return meta_context;
	}

	/**
	 * Schedules the document title update: 20 seconds after the user's last
	 * edit, the conversation_title utility is run on the document content.
	 * @param {string} guid - The document conversation GUID.
	 */
	schedule_document_title_update(guid) {
		if (!guid) return;
		if (this.#document_title_timer) {
			clearTimeout(this.#document_title_timer);
		}
		this.#document_title_timer = setTimeout(() => {
			this.#document_title_timer = null;
			void this.update_document_title(guid);
		}, 20000);
	}

	/**
	 * Applies a title to a document conversation, cancels any pending
	 * utility-based title update, and refreshes the UI.
	 * @param {string} guid - The document conversation GUID.
	 * @param {string} title - The new title.
	 */
	apply_document_title(guid, title) {
		const trimmed = (title || '').trim();
		if (!trimmed || !guid) return;
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE]) return;
		if (this.#document_title_timer) {
			clearTimeout(this.#document_title_timer);
			this.#document_title_timer = null;
		}
		if (this.#ranking_title_timer) {
			clearTimeout(this.#ranking_title_timer);
			this.#ranking_title_timer = null;
		}
		if (this.#sticky_title_timer) {
			clearTimeout(this.#sticky_title_timer);
			this.#sticky_title_timer = null;
		}
		if (conversation[this.app.storage.KEY_CONVERSATION_TITLE] === trimmed) return;
		conversation[this.app.storage.KEY_CONVERSATION_TITLE] = trimmed;
		this.app.storage.save_conversation(guid, conversation);
		this.app.storage.update_app_index(guid);
		this.app.content_list?.on_app_index_updated?.();
		this.app.on_conversation_updated(false);
	}

	/**
	 * Runs the conversation_title utility on the document's text content and
	 * applies the returned title to the document conversation.
	 * @param {string} guid - The document conversation GUID.
	 */
	async update_document_title(guid) {
		if (this.#document_title_timer) {
			clearTimeout(this.#document_title_timer);
			this.#document_title_timer = null;
		}
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_DOCUMENT) return;
		const items = conversation[this.app.storage.KEY_CONVERSATION_DOCUMENT];
		if (!Array.isArray(items)) return;
		const text = items
			.map(item => (item?.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())
			.filter(Boolean)
			.join('\n')
			.slice(0, 4000);
		if (!text) return;
		try {
			const response = await this.app.api.evaluate_utility(text, 'conversation_title');
			const title = (response?.explanation || '').trim();
			if (!title || response?.evaluation === false) return;
			this.apply_document_title(guid, title);
		} catch (error) {
			console.warn('Document title update failed:', error);
		}
	}

	/**
	 * Schedules the scratchpad title update a few seconds after a request's
	 * edits completed; a follow-up request restarts the timer so the
	 * conversation_title utility runs once on the final scratchpad state.
	 * @param {string} guid - The scratchpad conversation GUID.
	 */
	schedule_scratchpad_title_update(guid) {
		if (!guid) return;
		if (this.#scratchpad_title_timer) {
			clearTimeout(this.#scratchpad_title_timer);
		}
		this.#scratchpad_title_timer = setTimeout(() => {
			this.#scratchpad_title_timer = null;
			void this.update_scratchpad_title(guid);
		}, 5000);
	}

	/**
	 * Runs the conversation_title utility on the full scratchpad content and
	 * applies the returned title to the conversation and its latest revision
	 * (whose conversation title is the one displayed in the indexes).
	 * @param {string} guid - The scratchpad conversation GUID.
	 */
	async update_scratchpad_title(guid) {
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) return;
		const history = conversation[this.app.storage.KEY_CONVERSATION_HISTORY] || [];
		const revision = history[history.length - 1];
		const items = Array.isArray(revision?.items) ? revision.items : [];
		const text = items
			.map(item => (item?.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())
			.filter(Boolean)
			.join('\n')
			.slice(0, 4000);
		if (!text) return;
		try {
			const response = await this.app.api.evaluate_utility(text, 'conversation_title');
			const title = (response?.explanation || '').trim();
			if (!title || response?.evaluation === false) return;
			const current = conversation[this.app.storage.KEY_CONVERSATION_TITLE];
			if (current === title) return;
			conversation[this.app.storage.KEY_CONVERSATION_TITLE] = title;
			if (revision && typeof revision === 'object') {
				revision.conversationTitle = title;
			}
			this.app.storage.save_conversation(guid, conversation);
			this.app.storage.update_app_index(guid);
			this.app.scratchpad_index?.on_conversation_index_updated?.();
			this.app.content_list?.on_app_index_updated?.();
			const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
			if (guid === selected_guid) {
				this.app.on_conversation_updated(false);
			}
		} catch (error) {
			console.warn('Scratchpad title update failed:', error);
		}
	}

	/**
	 * Schedules the ranking conversation title update 3 seconds after the
	 * last choice edit, so the conversation_title utility runs once the
	 * user stops interacting with the choice list.
	 * @param {string} guid - The ranking conversation GUID.
	 */
	schedule_ranking_title_update(guid) {
		if (!guid) return;
		if (this.#ranking_title_timer) {
			clearTimeout(this.#ranking_title_timer);
		}
		this.#ranking_title_timer = setTimeout(() => {
			this.#ranking_title_timer = null;
			void this.update_ranking_title(guid);
		}, 3000);
	}

	/**
	 * Runs the conversation_title utility on the choice list when its hash
	 * differs from the one the current title was generated from, and applies
	 * the returned title to the ranking conversation.
	 * @param {string} guid - The ranking conversation GUID.
	 */
	async update_ranking_title(guid) {
		if (this.#ranking_title_timer) {
			clearTimeout(this.#ranking_title_timer);
			this.#ranking_title_timer = null;
		}
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_RANKING) return;
		const items = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
		const text = items
			.map(item => (item?.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())
			.filter(Boolean)
			.join('\n');
		const hash = hash_text(text);
		if (!text || hash === conversation[this.app.storage.KEY_CONVERSATION_CHOICES_HASH]) return;
		conversation[this.app.storage.KEY_CONVERSATION_CHOICES_HASH] = hash;
		this.app.storage.save_conversation(guid, conversation);
		try {
			const response = await this.app.api.evaluate_utility(text.slice(0, 4000), 'conversation_title');
			const title = (response?.explanation || '').trim();
			if (!title || response?.evaluation === false) return;
			this.apply_document_title(guid, title);
		} catch (error) {
			console.warn('Ranking title update failed:', error);
		}
	}

	/**
	 * Schedules the sticky notes title update a few seconds after the board
	 * changed, so the conversation_title utility runs once on the final state.
	 * @param {string} guid - The sticky notes conversation GUID.
	 */
	schedule_sticky_title_update(guid) {
		if (!guid) return;
		if (this.#sticky_title_timer) {
			clearTimeout(this.#sticky_title_timer);
		}
		this.#sticky_title_timer = setTimeout(() => {
			this.#sticky_title_timer = null;
			void this.update_sticky_title(guid);
		}, 3000);
	}

	/**
	 * Runs the conversation_title utility on the notes when their hash
	 * differs from the one the current title was generated from, and applies
	 * the returned title to the sticky notes conversation.
	 * @param {string} guid - The sticky notes conversation GUID.
	 */
	async update_sticky_title(guid) {
		if (this.#sticky_title_timer) {
			clearTimeout(this.#sticky_title_timer);
			this.#sticky_title_timer = null;
		}
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_STICKY_NOTES) return;
		const notes = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_STICKIES]) ? conversation[this.app.storage.KEY_CONVERSATION_STICKIES] : [];
		const text = notes
			.map(note => [note?.title, ...(note?.items || []).map(item => item?.text)].join(' ').replace(/\s+/g, ' ').trim())
			.filter(Boolean)
			.join('\n');
		const hash = hash_text(text);
		if (!text || hash === conversation[this.app.storage.KEY_CONVERSATION_STICKIES_HASH]) return;
		conversation[this.app.storage.KEY_CONVERSATION_STICKIES_HASH] = hash;
		this.app.storage.save_conversation(guid, conversation);
		try {
			const response = await this.app.api.evaluate_utility(text.slice(0, 4000), 'conversation_title');
			const title = (response?.explanation || '').trim();
			if (!title || response?.evaluation === false) return;
			this.apply_document_title(guid, title);
		} catch (error) {
			console.warn('Sticky notes title update failed:', error);
		}
	}

	/**
	 * Displays the progress indicator UI and stop button during request processing.
	 * @param {string|null} [guid=null] - Optional conversation GUID.
	 */
	show_progress_ui(guid = null) {
		setElementDisplay(getEl('div-prompt-input'), 'none');
		setElementDisplay(getEl('div-prompt-clarification'), 'none');

		const progressDiv = getEl('div-response-progress');
		if (!progressDiv) return;
		progressDiv.innerHTML = '';
		setElementDisplay(progressDiv, 'flex');

		const current_guid = guid || this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const bytes = this.get_pending_thinking_bytes(current_guid);

		const text = document.createElement('span');
		text.id = 'span-progress-text';
		text.textContent = bytes > 0 ? `Processing response... (${bytes})` : 'Processing response...';
		progressDiv.appendChild(text);

		if (this.show_chunks_enabled()) {
			const tokenSpan = document.createElement('span');
			tokenSpan.id = 'span-progress-tokens';
			tokenSpan.className = 'span-progress-tokens';
			progressDiv.appendChild(tokenSpan);
		}

		const stopBtn = document.createElement('button');
		stopBtn.id = 'btn-stop-response';
		stopBtn.textContent = 'Stop';
		stopBtn.className = 'as-icon';
		stopBtn.style.float = 'right';
		stopBtn.onclick = () => this.stop_response(current_guid);
		progressDiv.appendChild(stopBtn);

		if (this.app.btn_send) {
			this.app.btn_send.disabled = true;
			this.app.btn_send.textContent = '...';
		}
	}

	/**
	 * Updates the response progress label with received byte count.
	 * @param {number} bytes - Bytes received.
	 * @param {string} [guid] - Conversation GUID.
	 */
	update_progress(bytes, guid) {
		const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		if (guid && guid !== current_guid) return;
		const text = getEl('span-progress-text');
		if (text) {
			const displayBytes = (bytes !== undefined && bytes !== null && bytes > 0)
				? bytes
				: this.get_pending_thinking_bytes(guid || current_guid);
			text.textContent = displayBytes > 0 ? `Processing response... (${displayBytes})` : 'Processing response...';
		}
	}

	/**
	 * Renders the streamed token fade for the selected conversation's progress indicator.
	 * @param {string} [guid] - Conversation GUID whose tokens changed.
	 */
	update_token_display(guid) {
		if (!this.show_chunks_enabled()) return;
		const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		if (!guid || guid !== current_guid) return;
		render_token_fade(getEl('span-progress-tokens'), this.app.api.get_tokens(guid));
	}

	/**
	 * Hides the progress UI and restores the prompt input area.
	 */
	hide_progress_ui() {
		setElementDisplay(getEl('div-prompt-input'), 'block');
		setElementDisplay(getEl('div-prompt-clarification'), 'none');
		setElementDisplay(getEl('div-response-progress'), 'none');

		if (this.app.btn_send) {
			this.app.btn_send.textContent = 'Send';
			this.app.btn_send.disabled = !this.app.validate_prompt();
		}
	}

	/**
	 * Acquires a screen wake lock and initiates silent audio context to prevent background throttling.
	 * Only runs when both experimental features and background keep-alive are enabled.
	 * @returns {Promise<void>}
	 */
	async start_background_keep_alive() {
		const config = this.app.storage.get_app_config();
		const experimental_features_enabled = config[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
		const keep_alive_enabled = config[this.app.storage.KEY_CONFIG_BACKGROUND_KEEP_ALIVE] || false;
		if (!experimental_features_enabled || !keep_alive_enabled) return;

		try {
			if ('wakeLock' in navigator) {
				this.#wakeLock = await navigator.wakeLock.request('screen');
			}
		} catch (err) {
			console.warn('Wake Lock request failed:', err);
		}

		try {
			const AudioContextClass = window.AudioContext || window['webkitAudioContext'];
			if (AudioContextClass) {
				this.#audioCtx = new AudioContextClass();
				const oscillator = this.#audioCtx.createOscillator();
				const gainNode = this.#audioCtx.createGain();
				gainNode.gain.value = 0.0;
				oscillator.connect(gainNode);
				gainNode.connect(this.#audioCtx.destination);
				oscillator.start();
			}
		} catch (e) {
			console.warn('Audio Context keep-alive failed:', e);
		}
	}

	/**
	 * Releases wake lock and closes the background audio context only when no active requests remain.
	 */
	stop_background_keep_alive() {
		if (this.app.api && this.app.api.is_request_active()) {
			return;
		}

		if (this.#wakeLock) {
			this.#wakeLock.release().then(() => {
				this.#wakeLock = null;
			}).catch(() => {
				this.#wakeLock = null;
			});
		}

		if (this.#audioCtx) {
			this.#audioCtx.close().then(() => {
				this.#audioCtx = null;
			}).catch(() => {
				this.#audioCtx = null;
			});
		}
	}

	/**
	 * Aborts the active API request for the specified GUID (or currently selected conversation) and resets UI.
	 * @param {string} [targetGuid] - Optional GUID to abort.
	 */
	stop_response(targetGuid) {
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const guid = targetGuid || selected_guid;

		let stoppedPrompt = '';
		if (guid) {
			const conversation = this.app.storage.get_conversation(guid);
			const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
			if (Array.isArray(history) && history.length > 0 && history[history.length - 1].pending) {
				const pendingItem = history.pop();
				if (pendingItem && pendingItem.query) {
					stoppedPrompt = pendingItem.query;
				}
				conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
				this.app.storage.save_conversation(guid, conversation);
			}

			if (stoppedPrompt) {
				this.app.prompt_drafts[guid] = stoppedPrompt;
			} else if (this.app.prompt_drafts[guid]) {
				stoppedPrompt = this.app.prompt_drafts[guid];
			}

			this.app.api.abort_request(guid);
		} else {
			this.app.api.abort_all();
		}

		if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
			window.dispatchEvent(new CustomEvent('tinai:stop', {
				detail: { guid, prompt: stoppedPrompt }
			}));
			window.dispatchEvent(new CustomEvent('tinai:request-stop', {
				detail: { guid, prompt: stoppedPrompt }
			}));
		}

		this.stop_background_keep_alive();
		this.update_conversation_loading_indicators(guid, false);

		if (!guid || guid === selected_guid) {
			this.hide_progress_ui();

			if (this.app.prompt) {
				this.app.prompt.value = stoppedPrompt || this.app.prompt_drafts[guid] || '';
				this.app.resize_prompt_textarea();
				this.app.validate_prompt();
				this.app.prompt.focus();
			}

			if (this.app.btn_send) {
				this.app.btn_send.textContent = 'Send';
				this.app.btn_send.disabled = !this.app.validate_prompt();
			}

			this.app.on_conversation_updated(false);
		} else {
			this.app.content_list?.on_app_index_updated?.();
			this.app.scratchpad_index?.on_conversation_index_updated?.();
		}
	}

	/**
	 * Appends streamed thinking chunks to the currently pending history item for a specific conversation.
	 * @param {string|Array<string>} chunk - Streamed thinking text snippet.
	 * @param {string} [targetGuid] - Conversation GUID.
	 */
	send_thinking(chunk, targetGuid) {
		const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const guid = targetGuid || current_guid;
		if (!guid) return;

		const conversation = this.app.storage.get_conversation(guid);
		const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
		if (!history || history.length === 0) return;

		let pendingItem = history[history.length - 1];
		if (!pendingItem || !pendingItem.pending) {
			pendingItem = history.find(item => item && item.pending);
			if (!pendingItem) return;
		}

		if (!Array.isArray(pendingItem.thinking)) {
			pendingItem.thinking = pendingItem.thinking ? [pendingItem.thinking] : [];
		}

		if (Array.isArray(chunk)) {
			chunk.forEach(c => {
				if (typeof c === 'string' && c.trim().length > 0) {
					pendingItem.thinking.push(...c.split('\n').map(l => l.trim()).filter(l => l.length > 0));
				}
			});
		} else if (typeof chunk === 'string') {
			const sublines = chunk.split('\n').map(l => l.trim()).filter(l => l.length > 0);
			if (sublines.length > 0) {
				pendingItem.thinking.push(...sublines);
			}
		}

		conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
		this.app.storage.save_conversation(guid, conversation);

		if (guid === current_guid) {
			const bytes = this.get_pending_thinking_bytes(guid);
			const text = getEl('span-progress-text');
			if (text && bytes > 0) {
				text.textContent = `Processing response... (${bytes})`;
			}

			const lastIndex = history.indexOf(pendingItem);
			let thinkingContainer = this.app.div_response?.querySelector(`.div-thinking-content[data-index="${lastIndex}"]`);
			if (!thinkingContainer) {
				const allContainers = this.app.div_response?.querySelectorAll('.div-thinking-content');
				if (allContainers && allContainers.length > 0) {
					thinkingContainer = allContainers[allContainers.length - 1];
				}
			}

			if (thinkingContainer) {
				const textDiv = thinkingContainer.querySelector('.div-thinking-text');
				if (textDiv) {
					const lines = pendingItem.thinking;
					append_following_bottom(textDiv, () => {
						if (lines.length > 0) {
							textDiv.innerHTML = lines.map(line => formatThinkingLineHtml(line)).join('');
							lines.forEach(line => handleInteractiveRequest(line, guid, thinkingContainer, this.app));
						}
						if (!thinkingContainer.classList.contains('thinking-expanded') && !thinkingContainer.classList.contains('thinking-collapsed')) {
							thinkingContainer.classList.add('thinking-expanded');
							textDiv.style.display = 'block';
						} else if (thinkingContainer.classList.contains('thinking-expanded')) {
							textDiv.style.display = 'block';
						}
					});
				}
				thinkingContainer.dispatchEvent(new CustomEvent('thinking-updated', {
					bubbles: true,
					detail: { guid, index: lastIndex, thinking: pendingItem.thinking, chunk }
				}));
			} else {
				this.app.on_conversation_updated(false);
			}
		}
	}

	/**
	 * Callback handling API execution failure or cancellation.
	 * @param {Object} response - The API error response.
	 * @param {string} [targetGuid] - Conversation GUID.
	 */
	send_failure(response, targetGuid) {
		this.stop_background_keep_alive();

		const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const guid = targetGuid || current_guid;

		this.update_conversation_loading_indicators(guid, false);

		let stoppedPrompt = '';
		if (guid) {
			const conversation = this.app.storage.get_conversation(guid);
			const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
			if (history && history.length > 0 && history[history.length - 1].pending) {
				const pendingItem = history.pop();
				if (pendingItem && pendingItem.query) {
					stoppedPrompt = pendingItem.query;
				}
				conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
				this.app.storage.save_conversation(guid, conversation);
			}
		}

		if (stoppedPrompt) {
			this.app.prompt_drafts[guid] = stoppedPrompt;
		}

		if (current_guid === guid || !current_guid) {
			this.hide_progress_ui();

			if (this.app.prompt) {
				if (stoppedPrompt || this.app.prompt_drafts[guid]) {
					this.app.prompt.value = stoppedPrompt || this.app.prompt_drafts[guid] || '';
				}
				this.app.resize_prompt_textarea();
				this.app.validate_prompt();
				this.app.prompt.focus();
			}

			if (this.app.btn_send) {
				this.app.btn_send.textContent = 'Send';
				this.app.btn_send.disabled = !this.app.validate_prompt();
			}

			this.app.on_conversation_updated(false);

			if (response && (response.status === 'aborted' || response.name === 'AbortError')) {
				return;
			}

			const [title, message] = this.describe_api_failure(response);
			void customAlert(title, message);
		} else {
			this.app.content_list?.on_app_index_updated?.();
			this.app.scratchpad_index?.on_conversation_index_updated?.();
		}
	}

	/**
	 * Builds the alert dialog for an API call failure, clearly separating
	 * provider-side timeouts, provider API errors, and TinAI-side failures.
	 * @param {Object} response - The API error response.
	 * @returns {string[]} Dialog title and message.
	 */
	describe_api_failure(response) {
		const provider = response?.provider || 'Provider';
		const detail = (response?.raw_response || '').trim();
		switch (response?.error_type) {
			case 'provider_timeout':
				return [`${provider}: Provider Timed Out`, detail || 'The provider did not respond within the time limit.'];
			case 'provider_error':
				return [`${provider}: Provider Error`, response?.error || detail || 'The provider API reported a failure.'];
			case 'tinai_timeout':
				return ['TinAI API Timed Out', response?.error && response.error !== 'TinAI API timed out'
					? response.error
					: 'The request ran out of time on TinAI\'s server before the provider finished.'];
			case 'tinai_error':
				return ['TinAI API Error', response?.error || 'TinAI\'s server failed while handling the request.'];
			case 'insufficient_balance':
				return ['Insufficient Balance', response?.error || 'Your account balance is exhausted.'];
		}
		return ['Error', response?.error || response?.message || 'Something went wrong while communicating with the server.'];
	}

	/**
	 * Processes successful API responses, updates state, and renders the updated conversation.
	 * @param {ChatResponse} response - The API response object.
	 * @param {string} originalQuery - The original user prompt query.
	 * @param {string} [targetGuid] - The conversation GUID the response belongs to.
	 */
	send_success(response, originalQuery, targetGuid) {
		this.stop_background_keep_alive();
		this.app.play_alert_sound();

		const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const selected_guid = targetGuid || current_guid;

		this.update_conversation_loading_indicators(selected_guid, false);

		if (selected_guid) {
			delete this.app.prompt_drafts[selected_guid];
		}
		delete this.app.prompt_drafts[''];

		if (current_guid === selected_guid || !current_guid) {
			this.hide_progress_ui();
			if (this.app.btn_send) {
				this.app.btn_send.disabled = false;
				this.app.btn_send.textContent = 'Send';
			}
			if (this.app.prompt) {
				this.app.prompt.value = '';
				this.app.resize_prompt_textarea();
				this.app.validate_prompt();
				this.app.prompt.focus();
			}
		}

		if (!selected_guid) return;

		let conversation = this.app.storage.get_conversation(selected_guid);
		let history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY] || [];

		let pendingThinking = null;
		let pendingRevision = null;
		if (history && history.length > 0 && history[history.length - 1].pending) {
			const pendingItem = history.pop();
			if (pendingItem && pendingItem.thinking) {
				pendingThinking = pendingItem.thinking;
			}
			pendingRevision = pendingItem;
		}

		if (response.thinking) {
			if (typeof response.thinking === 'string') {
				response.thinking = response.thinking.split('\n').map(l => l.trim()).filter(l => l.length > 0);
			} else if (Array.isArray(response.thinking)) {
				response.thinking = response.thinking.flatMap(item => typeof item === 'string' ? item.split('\n').map(l => l.trim()).filter(l => l.length > 0) : item);
			}
		} else if (pendingThinking) {
			response.thinking = pendingThinking;
		}

		// scratchpad revisions carry the item state the request edited: the
		// response merges into that revision instead of replacing it
		let completed = response;
		if (pendingRevision && Array.isArray(pendingRevision.items)) {
			completed = Object.assign({}, response, {
				items: pendingRevision.items,
				toc: Array.isArray(pendingRevision.toc) ? pendingRevision.toc : []
			});
		}

		if (conversation && conversation[this.app.storage.KEY_CONVERSATION_TYPE] === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
			const conv_title = String(completed.conversationTitle || '').trim();
			if (conv_title) {
				conversation[this.app.storage.KEY_CONVERSATION_TITLE] = conv_title;
				completed.conversationTitle = conv_title;
			} else {
				this.schedule_scratchpad_title_update(selected_guid);
			}
		} else if (completed.title || completed.conversationTitle) {
			conversation[this.app.storage.KEY_CONVERSATION_TITLE] = completed.title || completed.conversationTitle;
		}
		if (completed.summary || completed.conversationSummary) {
			conversation[this.app.storage.KEY_CONVERSATION_SUMMARY] = completed.summary || completed.conversationSummary;
		}
		completed.query = originalQuery;
		history.push(completed);

		conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;

		if (completed.topics) {
			conversation[this.app.storage.KEY_CONVERSATION_TOPICS] = completed.topics;
		}
		if (completed.considerations) {
			conversation[this.app.storage.KEY_CONVERSATION_CONSIDERATIONS] = completed.considerations;
		}

		this.app.storage.save_conversation(selected_guid, conversation);
		this.app.storage.update_app_index(selected_guid);

		if (current_guid === selected_guid || !current_guid) {
			const wasFirstCompletedEntry = (history.length === 1);
			if (wasFirstCompletedEntry && window.innerWidth > this.app.BREAKPOINT_MOBILE && !this.app.state_i_open) {
				if (!this.app.state_v_open) {
					this.app.state_i_open = true;
					this.app.apply_panels_layout();
				}
			}

			this.app.on_conversation_updated();
			if (this.app.prompt) {
				this.app.prompt.focus();
			}

			const app_defaults = this.app.storage.get_app_defaults();
			const auto_run = (conversation && conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] !== undefined)
				? conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES]
				: (app_defaults?.[this.app.storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] ?? false);

			if (auto_run && completed.proposed_query && completed.proposed_query.trim().length > 0) {
				setTimeout(() => {
					const active_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
					if (active_guid === selected_guid) {
						this.send_suggested_query(completed.proposed_query);
					}
				}, 1000);
			}
		} else {
			this.app.content_list?.on_app_index_updated?.();
			this.app.scratchpad_index?.on_conversation_index_updated?.();
		}
	}

	/**
	 * Populates prompt textarea with a suggested query and initiates request execution.
	 * @param {string} query - The proposed query string.
	 */
	send_suggested_query(query) {
		if (this.app.prompt) {
			this.app.prompt.value = query;
			const config = this.app.storage.get_app_config();
			const current_guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
			this.app.prompt_drafts[current_guid] = query;
			this.app.resize_prompt_textarea();
			this.app.validate_prompt();
			this.send();
		}
	}
}

/**
 * Computes a compact hash of a text string, used to detect choice changes.
 * @param {string} text - The text to hash.
 * @returns {string} The hash.
 */
function hash_text(text) {
	let h = 5381;
	for (let i = 0; i < text.length; i++) {
		h = ((h << 5) + h + text.charCodeAt(i)) | 0;
	}
	return (h >>> 0).toString(36) + ':' + text.length.toString(36);
}

export default ResponsePipeline;
🌐
response-pipeline.js ×
Type: Web, text/plain
55.19 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File