0 directories, 46 files

util

Home / tinai / util
import { getEl } from './dom-utils.js';
import { scroller_of } from './scrollbars.js';

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

/**
 * Small chat UI helpers: scrolling, prompt validation, conversation counts, and sound alerts.
 */
class ChatHelpers {

	#scrollTimeout = null;

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

	/**
	 * Cancels any pending scheduled scroll operation.
	 */
	cancel_scroll() {
		if (this.#scrollTimeout) {
			clearTimeout(this.#scrollTimeout);
			this.#scrollTimeout = null;
		}
	}

	/**
	 * Scrolls the chat container to the most recent response in the history.
	 */
	scroll_to_last_item() {
		this.cancel_scroll();
		const config = this.app.storage.get_app_config();
		if (config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID]) {
			const history = this.get_selected_conversation()?.[this.app.storage.KEY_CONVERSATION_HISTORY];
			if (history && history.length > 0) {
				const lastIndex = history.length - 1;
				const lastItem = history[lastIndex];
				this.#scrollTimeout = setTimeout(() => {
					this.#scrollTimeout = null;
					if (lastItem && lastItem.pending) {
						const container = scroller_of(this.app.div_chat_container_scroll);
						if (container) {
							container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
						}
					} else {
						const lastEl = getEl('chat-item-' + lastIndex);
						if (lastEl) {
							lastEl.scrollIntoView({ behavior: 'smooth' });
						}
					}
				}, this.app.SCROLL_DELAY);
			}
		}
	}

	/**
	 * Returns the count of scratchpad conversations in the index.
	 * @returns {number} The count of scratchpad conversations.
	 */
	get_scratchpad_count() {
		const index = this.app.storage.get_app_index();
		let count = 0;
		(index || []).forEach(item => {
			const guid = item[this.app.storage.KEY_INDEX_GUID];
			const conversation = this.app.storage.get_conversation(guid);
			const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || item?.[this.app.storage.KEY_CONVERSATION_TYPE];
			if (type === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
				count++;
			}
		});
		return count;
	}

	/**
	 * Validates the state of the conversation index button: it is hidden
	 * whenever there is no index to show (welcome screen, scratchpad root
	 * selection, empty conversation, page documents), and otherwise labelled
	 * for what the index lists, always enabled. The label is stored on the
	 * app and rendered by update_nav_button_states with the ❌/tooltip
	 * open-close affordances.
	 */
	validate_conversation_index_button() {
		if (!this.app.btn_conversation_index) return;
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		let label = '';
		if (selected_guid && selected_guid !== 'scratchpad' && this.app.is_scratchpad_active()) {
			label = 'Scratchpads';
			const count = this.get_scratchpad_count();
			if (count === 0) {
				label = '';
				if (this.app.state_i_open) {
					this.app.state_i_open = false;
					this.app.apply_panels_layout();
				}
			}
		} else if (selected_guid && this.app.is_document_active()) {
			const doc_type = this.app.get_active_document_type();
			if (doc_type !== this.app.storage.DOCUMENT_TYPE_PAGE) {
				label = this.app.storage.get_document_index_label(doc_type);
			}
		} else if (selected_guid && (this.app.is_tools_active() || this.app.is_tool_conversation_active())) {
			label = 'Tools';
		} else if (selected_guid) {
			if (this.get_conversation_item_count() > 0) {
				label = 'History';
			}
		}
		this.app.index_button_label = label;
		this.app.update_nav_button_states();
	}

	/**
	 * Returns the number of responses in the currently selected conversation.
	 * @returns {number} The count of history items.
	 */
	get_conversation_item_count() {
		const config = this.app.storage.get_app_config();
		const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (guid) {
			const conversation = this.app.storage.get_conversation(guid);
			if (conversation && conversation[this.app.storage.KEY_CONVERSATION_HISTORY]) {
				return conversation[this.app.storage.KEY_CONVERSATION_HISTORY].length;
			}
		}
		return 0;
	}

	/**
	 * Checks whether the prompt textarea contains valid text.
	 * @returns {boolean} True if prompt is non-empty and conversation is idle.
	 */
	validate_prompt() {
		if (!this.app.btn_send) return false;
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const isActive = this.app.api.is_request_active(selected_guid);

		this.#validate_rank_button(selected_guid, isActive);

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

		this.app.btn_send.textContent = 'Send';
		if (this.app.prompt && this.app.prompt.value && this.app.prompt.value.trim().length > 0) {
			this.app.btn_send.disabled = false;
			return true;
		} else {
			this.app.btn_send.disabled = true;
			return false;
		}
	}

	/**
	 * Syncs the Rank button state: enabled only on ranking conversations with
	 * a non-empty prompt, an idle conversation, and at least two choices.
	 * @param {string} selected_guid - The selected conversation guid.
	 * @param {boolean} isActive - Whether a request is running for it.
	 */
	#validate_rank_button(selected_guid, isActive) {
		if (!this.app.btn_rank) return;
		const conversation = this.app.storage.get_conversation(selected_guid);
		const isRanking = !!conversation && conversation[this.app.storage.KEY_CONVERSATION_TYPE] === this.app.storage.CONVERSATION_TYPE_RANKING;
		if (!isRanking) return;

		const choices = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
		const hasPrompt = !!(this.app.prompt && this.app.prompt.value.trim().length > 0);
		this.app.btn_rank.textContent = 'Rank';
		this.app.btn_rank.disabled = isActive || !hasPrompt || choices.length < 2;
	}

	/**
	 * Adjusts the height of the prompt textarea to match its content.
	 */
	resize_prompt_textarea() {
		if (!this.app.prompt) return;
		this.app.prompt.style.height = 'auto';
		this.app.prompt.style.height = this.app.prompt.scrollHeight + 'px';
	}

	/**
	 * Checks if any scratchpad conversation currently has an active API request running.
	 * @returns {boolean}
	 */
	_is_any_scratchpad_loading() {
		const activeRequests = this.app.api.get_active_requests();
		return activeRequests.some(req => {
			if (!req.guid) return false;
			const conv = this.app.storage.get_conversation(req.guid);
			return conv && conv[this.app.storage.KEY_CONVERSATION_TYPE] === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD;
		});
	}

	/**
	 * Plays a subtle sound alert upon receiving a completion response.
	 */
	play_alert_sound() {
		const app_defaults = this.app.storage.get_app_defaults();
		const play_chime = app_defaults?.[this.app.storage.KEY_CONFIG_PLAY_CHIME] ?? false;
		if (!play_chime) return;

		try {
			const AudioContextClass = window.AudioContext || window['webkitAudioContext'];
			if (!AudioContextClass) return;

			const ctx = new AudioContextClass();
			const now = ctx.currentTime;

			const osc1 = ctx.createOscillator();
			const gain1 = ctx.createGain();
			osc1.type = 'sine';
			osc1.frequency.setValueAtTime(587.33, now); // D5
			gain1.gain.setValueAtTime(0.15, now);
			gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
			osc1.connect(gain1);
			gain1.connect(ctx.destination);
			osc1.start(now);
			osc1.stop(now + 0.15);

			const osc2 = ctx.createOscillator();
			const gain2 = ctx.createGain();
			osc2.type = 'sine';
			osc2.frequency.setValueAtTime(880, now + 0.1); // A5
			gain2.gain.setValueAtTime(0, now);
			gain2.gain.setValueAtTime(0.15, now + 0.1);
			gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
			osc2.connect(gain2);
			gain2.connect(ctx.destination);
			osc2.start(now + 0.1);
			osc2.stop(now + 0.35);
		} catch (e) {
			console.warn('Audio feedback failed:', e);
		}
	}

	/**
	 * Retrieves the conversation object for the currently selected GUID from storage.
	 * @returns {Object|null} The conversation object or null if none is selected.
	 */
	get_selected_conversation() {
		return this.app.storage.get_selected_conversation();
	}

	/**
	 * Checks if a response or history item contains valid content to be rendered or sent.
	 * @param {Object} item - History turn or response payload.
	 * @returns {boolean}
	 */
	_has_valid_content(item) {
		if (!item) return false;
		if (typeof item.content === 'string' && item.content.length > 0) return true;
		if (Array.isArray(item.content) && item.content.length > 0) return true;
		if (typeof item.content === 'object' && item.content !== null && Object.keys(item.content).length > 0) return true;
		if (Array.isArray(item.thinking) && item.thinking.length > 0) return true;
		return typeof item.thinking === 'string' && item.thinking.trim().length > 0;
	}
}

export default ChatHelpers;
🌐
chat-helpers.js ×
Type: Web, text/plain
8.85 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File