1 directories, 28 files

tinai

Home / testing / ai / tinai
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
import hljs from 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/es/highlight.min.js';

import Api from './api.js';
import Conversation from "./conversation.js";
import ConversationsList from "./conversations.js";
import ConversationIndex from "./conversation-index.js";
import Context from "./context.js";
import Memory from "./memory.js";
import NotebookIndex from "./notebook-index.js";
import Document from "./document.js";
import Diction from "./diction.js";

/**
 * Main application class for TinAI.
 * Manages UI state, layout, local storage synchronization, and coordinates
 * communication between the API service and conversation rendering.
 */
class App {

	SCROLL_DELAY = 250;
	BREAKPOINT_MOBILE = 780;
	BREAKPOINT_TABLET = 1560;

	#api = new Api();
	#conversation = new Conversation();
	#context;
	#memory = new Memory();
	#notebook_index;
	#document = new Document();
	#diction = new Diction();
	#storage;
	#conversations_list;
	#conversation_index;

	btn_empty_new_chat;
	btn_empty_new_notebook;
	btn_close;
	btn_costs;
	btn_app_options;
	btn_conversation_options;
	btn_options_close;
	btn_send;
	prompt;
	div_title;
	div_subtitle;
	div_list;
	div_index_list;
	div_response;
	select_theme;
	select_default_verbosity;
	select_conversation_verbosity;
	select_default_model;
	select_conversation_model;
	checkbox_experimental_features;
	checkbox_default_show_suggestions;
	checkbox_default_show_related;
	checkbox_default_enforce_topics;
	checkbox_default_auto_send_prompts;
	checkbox_conversation_show_suggestions;
	checkbox_conversation_show_related;
	checkbox_conversation_enforce_topics;
	checkbox_conversation_auto_send_prompts;
	btn_conversation_new;
	btn_conversation_list_new;
	btn_select_conversations;
	btn_archive_conversations;
	btn_delete_conversations;
	btn_archives_toggle;
	btn_show_conversations_new;
	btn_conversations;
	btn_conversation_index;
	btn_conversation_index_select;
	btn_conversation_index_favorite;
	btn_conversation_index_delete;
	div_chat_empty;
	div_chat_empty_header;
	div_chat_ui_elements;
	div_options_chips;
	span_option_model;
	span_option_verbosity;
	span_option_suggested;
	span_option_related;
	span_option_enforce_topics;
	span_option_auto_run;
	div_options_overlay;
	form_app_options;
	form_conversation_options;
	form_account_options;
	span_subtitle_toggle;
	btn_tab_conversation;
	btn_tab_context;
	btn_tab_memory;
	btn_tab_document;
	btn_tab_diction;
	btn_tab_notebook_memory;
	div_chat_container_scroll;
	div_chat_context_scroll;
	div_chat_memory_scroll;
	div_notebook_memory_scroll;
	div_chat_document_scroll;
	div_chat_diction_scroll;
	div_chat_prompt_inset;
	div_chat_prompt_container;
	div_chat_title_bar;
	div_chat_tab_bar;
	div_notebook_tab_bar;
	div_structure_center_notebook_options;
	div_structure_center_index_options;

	state_v_open;
	state_i_open;
	state_active_tab = 'conversation';
	resize_timeout;

	//region Initialization

	/**
	 * Initializes the application state, mermaid diagrams, UI elements, and event listeners.
	 * @param {Storage} storage
	 */
	constructor(storage) {
		this.#storage = storage;
		this.state_v_open = window.innerWidth > this.BREAKPOINT_MOBILE;
		this.state_i_open = window.innerWidth > this.BREAKPOINT_TABLET;

		mermaid.initialize({
			securityLevel: 'loose',
			theme: 'dark',
		});

		this.init_elements();
		this._populate_model_dropdowns();
		this.init_conversations_list();
		this.init_conversation_index();
		this.init_notebook_index();
		this.init_context();
		this.init_listeners();
		this.init_interactions();
		this.on_app_config();
		this.#conversations_list.on_app_index_updated();
		this.handle_responsive_layout(window.innerWidth, window.innerWidth);
		this.apply_panels_layout();
		this.validate_prompt();

		this.scroll_to_last_item();
	}

	/**
	 * Maps DOM elements to class properties for easier access.
	 */
	init_elements() {
		this.btn_close = document.getElementById('btn-close');
		this.btn_costs = document.getElementById('btn-costs');
		this.btn_app_options = document.getElementById('btn-app-options');
		this.btn_conversation_options = document.getElementById('id-btn-conversation-options');
		this.btn_options_close = document.getElementById('btn-options-close');
		this.btn_send = document.getElementById('btn-send');
		this.prompt = document.getElementById('id-prompt');
		this.div_title = document.getElementById('id-div-response-title');
		this.div_subtitle = document.getElementById('id-div-response-subtitle');
		this.div_response = document.getElementById('id-div-response-render');
		this.select_theme = document.getElementById('id-select-theme');
		this.select_default_verbosity = document.getElementById('id-select-default-verbosity');
		this.select_conversation_verbosity = document.getElementById('id-select-conversation-verbosity');
		this.select_default_model = document.getElementById('id-select-default-model');
		this.select_conversation_model = document.getElementById('id-select-conversation-model');
		this.checkbox_experimental_features = document.getElementById('id-checkbox-experimental-features');
		this.checkbox_default_show_suggestions = document.getElementById('id-checkbox-default-show-suggestions');
		this.checkbox_default_show_related = document.getElementById('id-checkbox-default-show-related');
		this.checkbox_default_enforce_topics = document.getElementById('id-checkbox-default-enforce-topics');
		this.checkbox_default_auto_send_prompts = document.getElementById('id-checkbox-default-auto-send-prompts');
		this.checkbox_conversation_show_suggestions = document.getElementById('id-checkbox-conversation-show-suggestions');
		this.checkbox_conversation_show_related = document.getElementById('id-checkbox-conversation-show-related');
		this.checkbox_conversation_enforce_topics = document.getElementById('id-checkbox-conversation-enforce-topics');
		this.checkbox_conversation_auto_send_prompts = document.getElementById('id-checkbox-conversation-auto-send-prompts');
		this.div_list = document.getElementById('id-div-list');
		this.div_index_list = document.getElementById('id-div-center-index-list');
		this.btn_conversation_new = document.getElementById('id-btn-conversation-new');
		this.btn_conversation_list_new = document.getElementById('id-btn-conversation-list-new');
		this.btn_select_conversations = document.getElementById('id-btn-select-conversations');
		this.btn_archive_conversations = document.getElementById('id-btn-archive-conversation');
		this.btn_archives_toggle = document.getElementById('id-btn-conversation-archives');
		this.btn_delete_conversations = document.getElementById('id-btn-delete-conversation');
		this.btn_empty_new_chat = document.getElementById('id-btn-empty-new-chat');
		this.btn_empty_new_notebook = document.getElementById('id-btn-empty-new-notebook');
		this.btn_show_conversations_new = document.getElementById('id-btn-show-conversations-new');
		this.btn_conversations = document.getElementById('id-btn-conversations');
		this.btn_conversation_index = document.getElementById('id-btn-conversation-index');
		this.btn_conversation_index_select = document.getElementById('id-btn-conversation-index-select');
		this.btn_conversation_index_favorite = document.getElementById('id-btn-conversation-index-favorite');
		this.btn_conversation_index_delete = document.getElementById('id-btn-conversation-index-delete');
		this.div_chat_empty = document.querySelector('.div-chat-empty');
		this.div_chat_empty_header = document.querySelector('.div-chat-empty-header');
		this.div_chat_ui_elements = document.querySelectorAll('.div-chat-ui-elements');
		this.div_options_chips = document.querySelector('.div-options-chips');
		this.span_option_model = document.getElementById('id-span-option-model');
		this.span_option_verbosity = document.getElementById('id-span-option-verbosity');
		this.span_option_suggested = document.getElementById('id-span-option-suggested');
		this.span_option_related = document.getElementById('id-span-option-related');
		this.span_option_enforce_topics = document.getElementById('id-span-option-enforce-topics');
		this.span_option_auto_run = document.getElementById('id-span-option-auto-run');
		this.div_options_overlay = document.querySelector('.div-structure-dialog-overlay');
		this.form_app_options = document.getElementById('id-form-app-options');
		this.form_conversation_options = document.getElementById('id-form-conversation-options');
		this.form_account_options = document.getElementById('id-form-account-options');
		this.span_subtitle_toggle = document.getElementById('id-span-subtitle-toggle');
		this.btn_tab_conversation = document.getElementById('id-btn-tab-conversation');
		this.btn_tab_context = document.getElementById('id-btn-tab-context');
		this.btn_tab_memory = document.getElementById('id-btn-tab-memory');
		this.btn_tab_document = document.getElementById('id-btn-tab-document');
		this.btn_tab_diction = document.getElementById('id-btn-tab-diction');
		this.btn_tab_notebook_memory = document.getElementById('id-btn-tab-notebook-memory');
		this.div_chat_container_scroll = document.getElementById('id-chat-container-scroll');
		this.div_chat_context_scroll = document.getElementById('div-chat-context-scroll');
		this.div_chat_memory_scroll = document.getElementById('div-chat-memory-scroll');
		this.div_notebook_memory_scroll = document.getElementById('div-notebook-memory-scroll');
		this.div_chat_document_scroll = document.getElementById('div-chat-document-scroll');
		this.div_chat_diction_scroll = document.getElementById('div-chat-diction-scroll');
		this.div_chat_prompt_inset = document.getElementById('div-chat-prompt-inset');
		this.div_chat_prompt_container = document.getElementById('div-chat-prompt-container');
		this.div_chat_title_bar = document.querySelector('.div-chat-title-bar');
		this.div_chat_tab_bar = document.querySelector('.div-chat-tab-bar');
		this.div_notebook_tab_bar = document.querySelector('.div-notebook-tab-bar');
		this.div_structure_center_notebook_options = document.getElementById('div-structure-center-notebook-options');
		this.div_structure_center_index_options = document.querySelector('.div-structure-center-index-options');
		this._reset_chat_view()
	}

	/**
	 * Populates the model dropdowns with options from Api.MODELS.
	 * @private
	 */
	_populate_model_dropdowns() {
		const models = Api.MODELS;
		const defaultModelSelect = this.select_default_model;
		const conversationModelSelect = this.select_conversation_model;

		// Clear existing options
		defaultModelSelect.innerHTML = '';
		conversationModelSelect.innerHTML = '';

		for (const key in models) {
			if (models.hasOwnProperty(key)) {
				const model = models[key];
				const option = document.createElement('option');
				option.value = key;
				option.textContent = model.name + ' (' + model.description + ')';

				defaultModelSelect.appendChild(option.cloneNode(true));
				conversationModelSelect.appendChild(option.cloneNode(true));
			}
		}
	}

	/**
	 * Initializes the ConversationsList instance.
	 */
	init_conversations_list() {
		const app_callbacks = {
			update_app_config: this.#storage.update_app_config.bind(this.#storage),
			apply_panels_layout: this.apply_panels_layout.bind(this),
			render_conversation_header: this.render_conversation_header.bind(this),
			close_conversation: this.close_conversation.bind(this),
			set_v_open: (value) => { this.state_v_open = value; },
			set_i_open: (value) => { this.state_i_open = value; },
			on_conversation_updated: this.on_conversation_updated.bind(this),
			set_active_tab: this.set_active_tab.bind(this)
		};

		const elements = {
			div_list: this.div_list,
			div_title: this.div_title,
			btn_select_conversations: this.btn_select_conversations,
			btn_archive_conversations: this.btn_archive_conversations,
			btn_delete_conversations: this.btn_delete_conversations,
			btn_archives_toggle: this.btn_archives_toggle,
			btn_conversations: this.btn_conversations,
			btn_show_conversations_new: this.btn_show_conversations_new
		};

		const breakpoints = {
			BREAKPOINT_MOBILE: this.BREAKPOINT_MOBILE
		};

		this.#conversations_list = new ConversationsList(this.#storage, app_callbacks, elements, breakpoints);
	}

	/**
	 * Initializes the ConversationIndex instance.
	 */
	init_conversation_index() {
		const app_callbacks = {
			get_selected_conversation: this.get_selected_conversation.bind(this),
			set_i_open: (value) => { this.state_i_open = value; },
			apply_panels_layout: this.apply_panels_layout.bind(this),
			on_conversation_updated_main_panel: this.on_conversation_updated.bind(this)
		};

		const elements = {
			div_index_list: this.div_index_list,
			btn_conversation_index_select: this.btn_conversation_index_select,
			btn_conversation_index_favorite: this.btn_conversation_index_favorite,
			btn_conversation_index_delete: this.btn_conversation_index_delete
		};

		const breakpoints = {
			BREAKPOINT_MOBILE: this.BREAKPOINT_MOBILE
		};

		this.#conversation_index = new ConversationIndex(this.#storage, app_callbacks, elements, breakpoints, this.SCROLL_DELAY);
	}

	init_notebook_index() {
		this.#notebook_index = new NotebookIndex(this.#storage, {});
	}

	/**
	 * Initializes the Context instance.
	 */
	init_context() {
		const app_callbacks = {
			saveConversation: (conversation) => {
				const guid = conversation[this.#storage.KEY_CONVERSATION_GUID];
				if (guid) {
					this.#storage.save_conversation(guid, conversation);
					this.#storage.update_app_index(guid, false);
				}
			},
			renderContext: this.render_context_tab.bind(this)
		};
		this.#context = new Context(this.#storage, app_callbacks);
	}

	/**
	 * Sets up event listeners for window storage changes, window resizing, and scroll events.
	 */
	init_listeners() {
		window.addEventListener('storage', (event) => {
			console.log('Storage changed:', event);
			this.handle_storage_change(event);
		});

		let lastWidth = window.innerWidth;
		const debouncedResize = this.debounce(() => {
			const currentWidth = window.innerWidth;
			this.handle_responsive_layout(lastWidth, currentWidth);
			this.resize_prompt_textarea();
			lastWidth = currentWidth;
			this.apply_panels_layout();
		}, 100);

		window.addEventListener('resize', debouncedResize);

		const scrollContainer = document.querySelector('.div-chat-container-scroll');
		if (scrollContainer) {
			scrollContainer.addEventListener('scroll', () => {
				this.#conversation_index.highlight_active_index_item();
			});
		}
	}
	/**
	 * Binds user interaction events such as clicks and keyboard inputs to their respective handlers.
	 */
	init_interactions(){
		this.btn_send.onclick = this.send.bind(this);
		this.prompt.oninput = () => {
			this.validate_prompt();
			this.resize_prompt_textarea();
		};
		this.prompt.onkeydown = this.handle_keydown.bind(this);
		this.select_theme.onchange = (e) => this.update_app_options_setting(e);
		this.select_default_verbosity.onchange = (e) => this._update_setting('app', e);
		this.select_conversation_verbosity.onchange = (e) => this._update_setting('conversation', e);
		this.select_default_model.onchange = (e) => this._update_setting('app', e);
		this.select_conversation_model.onchange = (e) => this._update_setting('conversation', e);
		this.checkbox_experimental_features.onchange = (e) => this.update_experimental_features_setting(e);
		this.checkbox_default_show_suggestions.onchange = (e) => this._update_setting('app', e);
		this.checkbox_default_show_related.onchange = (e) => this._update_setting('app', e);
		this.checkbox_default_enforce_topics.onchange = (e) => this._update_setting('app', e);
		this.checkbox_default_auto_send_prompts.onchange = (e) => this._update_setting('app', e);
		this.checkbox_conversation_show_suggestions.onchange = (e) => this._update_setting('conversation', e);
		this.checkbox_conversation_show_related.onchange = (e) => this._update_setting('conversation', e);
		this.checkbox_conversation_enforce_topics.onchange = (e) => this._update_setting('conversation', e);
		this.checkbox_conversation_auto_send_prompts.onchange = (e) => this._update_setting('conversation', e);
		this.btn_conversation_new.onclick = () => this.#conversations_list.index_new(this.#storage.CONVERSATION_TYPE_CHAT);
		this.btn_conversation_list_new.onclick = this.close_conversation.bind(this);
		this.btn_empty_new_chat.onclick = () => this.#conversations_list.index_new(this.#storage.CONVERSATION_TYPE_CHAT);
		this.btn_empty_new_notebook.onclick = () => this.#conversations_list.index_new(this.#storage.CONVERSATION_TYPE_NOTEBOOK);
		this.btn_show_conversations_new.onclick = this.toggle_conversation_panel.bind(this);
		this.btn_conversations.onclick = this.toggle_conversation_panel.bind(this);
		this.btn_conversation_index.onclick = this.toggle_conversation_index.bind(this);
		this.btn_close.onclick = this.close_conversation.bind(this);
		this.btn_select_conversations.onclick = this.#conversations_list.toggle_conversation_selection_mode.bind(this.#conversations_list);
		this.btn_archive_conversations.onclick = this.#conversations_list.archive_selected_conversations.bind(this.#conversations_list);
		this.btn_archives_toggle.onclick = this.#conversations_list.toggle_archives.bind(this.#conversations_list);
		this.btn_delete_conversations.onclick = this.#conversations_list.delete_selected_conversations.bind(this.#conversations_list);
		this.btn_conversation_index_select.onclick = this.#conversation_index.toggle_response_selection_mode.bind(this.#conversation_index);
		this.btn_conversation_index_favorite.onclick = this.#conversation_index.bookmark_selected_responses.bind(this.#conversation_index);
		this.btn_conversation_index_delete.onclick = this.#conversation_index.delete_selected_responses.bind(this.#conversation_index);
		this.btn_app_options.onclick = this.show_app_options.bind(this);
		this.btn_conversation_options.onclick = this.show_conversation_options.bind(this);
		this.btn_options_close.onclick = this.hide_options_overlay.bind(this);
		this.div_options_overlay.onclick = this.handle_options_overlay_click.bind(this);
		this.span_subtitle_toggle.onclick = this.toggle_subtitle.bind(this);
		this.div_title.onclick = this.toggle_subtitle.bind(this);
		this.btn_tab_conversation.onclick = () => this.set_active_tab('conversation');
		this.btn_tab_context.onclick = () => this.set_active_tab('context');
		this.btn_tab_memory.onclick = () => this.set_active_tab('memory');
		this.btn_tab_document.onclick = () => this.set_active_tab('document');
		this.btn_tab_diction.onclick = () => this.set_active_tab('diction');
		this.btn_tab_notebook_memory.onclick = () => this.set_active_tab('notebook-memory');
		this.span_option_suggested.onclick = () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES);
		this.span_option_related.onclick = () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES);
		this.span_option_enforce_topics.onclick = () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS);
		this.span_option_auto_run.onclick = () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES);
	}

	/**
	 * Handles keydown events in the prompt textarea.
	 * @param {KeyboardEvent} e The keyboard event object.
	 */
	handle_keydown(e) {
		if (e.ctrlKey && e.key === 'Enter') {
			e.preventDefault();
			if (!this.btn_send.disabled) {
				this.send();
			}
		}
	}

	//endregion

	//region Configuration & Local Storage

	/**
	 * Responds to localStorage changes, ensuring application state remains synced across different browser tabs.
	 * @param {StorageEvent} event The storage event object containing change details.
	 */
	handle_storage_change(event) {
		if (event.key === this.#storage.KEY_APP_CONFIG) {
			this.on_app_config();
		}
		if (event.key === this.#storage.KEY_APP_INDEX) {
			this.#conversations_list.on_app_index_updated();
		}
		if (event.key === this.#storage.KEY_CONFIG_DEFAULTS) {
			this.apply_app_defaults();
		}
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (event.key === this.#storage.KEY_APP_CONVERSATION_PREFIX + selected_guid) {
			this.on_conversation_updated();
		}
	}

	/**
	 * Updates the UI components when the global application configuration changes.
	 */
	on_app_config(){
		this.apply_app_options();
		this.apply_app_defaults();
		this.apply_conversation_options();
		this.#conversations_list.apply_selected_index_class();
		this.on_conversation_updated();

		const config = this.#storage.get_app_config();
		const experimental_features_enabled = config[this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
		this.btn_empty_new_notebook.style.display = experimental_features_enabled ? 'inline-block' : 'none';

		if (!experimental_features_enabled && (this.state_active_tab === 'memory' || this.state_active_tab === 'notebook-memory')) {
			this.set_active_tab('conversation');
		}
	}

	/**
	 * Saves the currently selected theme from the theme selector to the application configuration.
	 */
	update_app_options_setting(e) {
		const key = e.target.id === 'id-select-theme' ? this.#storage.KEY_CONFIG_THEME : null;
		if (key) {
			this.#storage.update_app_config(key, e.target.value);
		}
	}

	/**
	 * Updates the experimental features setting in the application configuration.
	 * @param {Event} e The change event from the checkbox.
	 */
	update_experimental_features_setting(e) {
		this.#storage.update_app_config(this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES, e.target.checked);
	}

	/**
	 * Applies the selected verbosity and model settings to the UI.
	 */
	apply_conversation_options() {
		this._apply_options('conversation');
	}

	/**
	 * Applies the default verbosity and model settings to the UI.
	 */
	apply_app_defaults() {
		this._apply_options('app');
	}

	/**
	 * Applies the selected CSS theme and updates Highlight.js stylesheet based on the configuration.
	 */
	apply_app_options() { // Renamed
		const loader = document.querySelector('.page-loader');
		const themes = ['theme_dark', 'theme_light'];
		const config = this.#storage.get_app_config();
		let theme = (config && config[this.#storage.KEY_CONFIG_THEME]) ? config[this.#storage.KEY_CONFIG_THEME] : 'theme_dark';

		if (!themes.includes(theme)) {
			theme = 'theme_dark';
		}

		this.select_theme.value = theme;

		const hljs_theme = theme.includes('light')
			? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css'
			: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/dark.min.css';

		const required_hrefs = [
			hljs_theme,
			'dynamic.php?s=base&t=' + theme,
			'dynamic.php?s=style&t=' + theme
		];

		const existing_links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
		const current_hrefs = existing_links.map(link => link.getAttribute('href'));

		const is_already_applied = required_hrefs.every(href => current_hrefs.includes(href));

		if (!is_already_applied) {
			if (loader){
				loader.style.display = 'block';
				loader.style.opacity = '0%';
				// Force reflow to ensure transition triggers
				loader.offsetHeight;
				loader.style.opacity = '100%';
			}
			const old_links = existing_links.filter(link => {
				const href = link.getAttribute('href') || '';
				return href.includes('dynamic.php?s=') || href.includes('highlight.js/11.11.1/styles/');
			});

			let loaded_count = 0;
			const on_link_load = () => {
				loaded_count++;
				if (loaded_count === required_hrefs.length) {
					old_links.forEach(link => link.remove());
					if (loader) {
						loader.style.opacity = '0%';
						loader.addEventListener('transitionend', () => {
							loader.style.display = 'none';
						}, { once: true });
					}
				}
			};

			required_hrefs.forEach(href => {
				let link = document.createElement('link');
				link.rel = 'stylesheet';
				link.href = href;
				link.onload = on_link_load;
				link.onerror = on_link_load;
				document.head.appendChild(link);
			});
		} else {
			if (loader) {
				loader.style.opacity = '0%';
				loader.addEventListener('transitionend', () => {
					loader.style.display = 'none';
				}, { once: true });
			}
		}
	}

	//endregion

	//region Layout & Responsive

	/**
	 * Adjusts the prompt placeholder text and panel visibility states based on window resize events.
	 * @param {number} lastWidth The previous window width.
	 * @param {number} currentWidth The new current window width.
	 */
	handle_responsive_layout(lastWidth, currentWidth) {
		if (currentWidth <= this.BREAKPOINT_MOBILE) {
			this.prompt.placeholder = 'Ctrl + βž₯ to Send';
		} else {
			this.prompt.placeholder = 'Ctrl + Enter to Send';
		}

		const config = this.#storage.get_app_config();
		const isSelected = !!config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		// Transitioning from tablet/desktop to mobile
		if (lastWidth > this.BREAKPOINT_MOBILE && currentWidth <= this.BREAKPOINT_MOBILE && isSelected) {
			this.state_v_open = false;
			this.state_i_open = false;
		}

		// Transitioning from mobile to tablet/desktop
		if (lastWidth <= this.BREAKPOINT_MOBILE && currentWidth > this.BREAKPOINT_MOBILE) {
			this.state_v_open = true;
			this.state_i_open = false;
		}

		// Transitioning from desktop to tablet (ensure only one panel open)
		if (lastWidth > this.BREAKPOINT_TABLET && currentWidth <= this.BREAKPOINT_TABLET) {
			if (this.state_v_open && this.state_i_open) {
				this.state_i_open = false;
			}
		}
	}

	/**
	 * Toggles the visibility of the left-side conversations navigation panel.
	 */
	toggle_conversation_panel() {
		if (window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_i_open = false;
		}
		this.state_v_open = !this.state_v_open;
		if (this.state_v_open && window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_i_open = false;
		}
		this.apply_panels_layout();
	}

	/**
	 * Toggles the visibility of the center conversation index (response list) panel.
	 */
	toggle_conversation_index() {
		if (window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_v_open = false;
		}
		this.state_i_open = !this.state_i_open;
		if (this.state_i_open && window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_v_open = false;
		}
		this.apply_panels_layout();
	}

	/**
	 * Updates the main layout container's CSS classes to reflect the current open/closed states of side panels.
	 */
	apply_panels_layout(){

		const config = this.#storage.get_app_config();
		if (window.innerWidth <= this.BREAKPOINT_MOBILE && !config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID]) {}

		const body = document.getElementById('id-div-structure-body');

		// Remove all possible layout classes first to ensure only the correct one is applied
		body.classList.remove('div-structure-body-v-i-c');
		body.classList.remove('div-structure-body-v-c');
		body.classList.remove('div-structure-body-i-c');
		body.classList.remove('div-structure-body-c');

		if(this.state_v_open && this.state_i_open){
			body.classList.add('div-structure-body-v-i-c');
		} else if(this.state_v_open && !this.state_i_open){
			body.classList.add('div-structure-body-v-c');
		} else if(!this.state_v_open && this.state_i_open){
			body.classList.add('div-structure-body-i-c');
		} else { // !this.state_v_open && !this.state_i_open
			body.classList.add('div-structure-body-c');
		}

		// Apply/remove underlined-button class based on panel state
		if (this.state_v_open) {
			this.btn_conversations.classList.add('underlined-button');
		} else {
			this.btn_conversations.classList.remove('underlined-button');
		}

		if (this.state_i_open) {
			this.btn_conversation_index.classList.add('underlined-button');
		} else {
			this.btn_conversation_index.classList.remove('underlined-button');
		}
	}

	//endregion

	//region Conversation / Chat

	/**
	 * Sets the active tab and updates the UI accordingly.
	 * @param {string} tabName - The name of the tab to activate ('conversation', 'context', or 'memory').
	 */
	set_active_tab(tabName) {
		const conversation = this.get_selected_conversation();
		const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE] || this.#storage.CONVERSATION_TYPE_CHAT;

		const config = this.#storage.get_app_config();
		const experimental_features_enabled = config[this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;

		this.state_active_tab = tabName;
		this._update_tab_visibility(tabName, type, experimental_features_enabled);

		// Call the render function for the active tab after updating all displays
		const tabs = {
			conversation: { render: () => this.render_conversation_tab(false) },
			context: { render: this.render_context_tab.bind(this) },
			memory: { render: this.render_memory_tab.bind(this) },
			document: { render: this.render_document_tab.bind(this) },
			diction: { render: this.render_diction_tab.bind(this) },
			'notebook-memory': { render: this.render_memory_tab.bind(this) }
		};

		if (tabs[tabName] && tabs[tabName].render) {
			tabs[tabName].render();
		}
	}

	/**
	 * Renders the content for the context tab.
	 */
	render_context_tab() {
		this.div_chat_context_scroll.innerHTML = this.#context.render();
		this.#context.attachEventListeners();
	}

	/**
	 * Renders the content for the memory tab.
	 */
	render_memory_tab() {
		const conversation = this.get_selected_conversation();
		const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE] || this.#storage.CONVERSATION_TYPE_CHAT;
		if (type === this.#storage.CONVERSATION_TYPE_CHAT) {
			this.div_chat_memory_scroll.innerHTML = this.#memory.render();
		} else {
			this.div_notebook_memory_scroll.innerHTML = this.#memory.render();
		}
	}

	render_document_tab() {
		this.div_chat_document_scroll.innerHTML = this.#document.render();
	}

	render_diction_tab() {
		this.div_chat_diction_scroll.innerHTML = this.#diction.render();
	}

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

	/**
	 * Renders the chat interface header, displaying the title and summary of the selected conversation.
	 */
	render_conversation_header() {
		const conversation = this.get_selected_conversation();
		const history = conversation?.[this.#storage.KEY_CONVERSATION_HISTORY] || [];

		let title = conversation?.[this.#storage.KEY_CONVERSATION_TITLE] || 'New Conversation';
		let summary = conversation?.[this.#storage.KEY_CONVERSATION_SUMMARY] || '';

		if (history.length > 0) {
			const lastItem = history[history.length - 1];
			title = lastItem.title;
			summary = lastItem.summary;
		}

		// Update div_title
		let h4_title = this.div_title.querySelector('h4');
		if (h4_title) {
			h4_title.innerHTML = title;
		} else {
			// If h4 doesn't exist, create it and prepend it.
			this.div_title.insertAdjacentHTML('afterbegin', `<h4>${title}</h4>`);
		}

		// Update div_subtitle without overwriting its classes
		let h6_subtitle = this.div_subtitle.querySelector('h6');
		if (h6_subtitle) {
			h6_subtitle.innerHTML = summary;
		} else {
			// If h6 doesn't exist, create it and prepend it to div_subtitle.
			this.div_subtitle.insertAdjacentHTML('afterbegin', `<h6>${summary}</h6>`);
		}
	}

	/**
	 * Toggles the visibility of the subtitle and rotates the toggle icon.
	 */
	toggle_subtitle() {
		const is_shown = this.div_subtitle.classList.toggle('subtitle-shown');
		this.span_subtitle_toggle.classList.toggle('rotated', is_shown);
		this.div_subtitle.style.maxHeight = is_shown ? this.div_subtitle.scrollHeight + 'px' : null;
	}

	/**
	 * Scrolls the chat container to the most recent response in the history.
	 */
	scroll_to_last_item() {
		const config = this.#storage.get_app_config();
		if (config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID]) {
			const history = this.get_selected_conversation()?.[this.#storage.KEY_CONVERSATION_HISTORY];
			if (history && history.length > 0) {
				const lastIndex = history.length - 1;
				setTimeout(() => {
					const lastEl = document.getElementById('chat-item-' + lastIndex);
					if (lastEl) {
						lastEl.scrollIntoView({ behavior: 'auto' });
					}
				}, this.SCROLL_DELAY);
			}
		}
	}

	/**
	 * Deselects the current conversation, closes associated panels, and resets the interface.
	 */
	close_conversation() {
		this.state_i_open = false;
		if (window.innerWidth <= this.BREAKPOINT_MOBILE) {
			this.state_v_open = false; // Close conversation list in mobile
		} else {
			this.state_v_open = true; // Keep conversation list open in desktop/tablet
		}
		this.#storage.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, '');
		this.apply_panels_layout();
	}

	/**
	 * Validates the state of the conversation index button.
	 * The button is disabled and the index panel is closed if no conversation is selected
	 * or if the selected conversation has no response history.
	 */
	validate_conversation_index_button() {
		const conversation = this.get_selected_conversation();
		const history = conversation ? conversation[this.#storage.KEY_CONVERSATION_HISTORY] : null;
		const has_sufficient_history = history && history.length > 1;

		this.btn_conversation_index.disabled = !has_sufficient_history;

		if (!has_sufficient_history) {
			this.state_i_open = false;
			this.apply_panels_layout();
		}
	}

	/**
	 * Validates the current prompt input and enables or disables the send button accordingly.
	 */
	validate_prompt() {
		this.btn_send.disabled = this.prompt.value.trim().length === 0;
	}

	/**
	 * Dynamically resizes the prompt textarea based on its content, up to a maximum of 4 rows.
	 */
	resize_prompt_textarea() {
		this.prompt.rows = 1; // Reset to 1 row to accurately calculate scrollHeight
		const lineHeight = parseInt(window.getComputedStyle(this.prompt).lineHeight);
		const padding = parseInt(window.getComputedStyle(this.prompt).paddingTop) + parseInt(window.getComputedStyle(this.prompt).paddingBottom);
		const scrollHeight = this.prompt.scrollHeight - padding;
		const newRows = Math.min(4, Math.ceil(scrollHeight / lineHeight))-1;
		this.prompt.rows = newRows;
	}

	/**
	 * Gathers the current prompt and its conversational context, then sends it to the backend API.
	 */
	send(){
		const max_summaries = 20;
		const max_full_values = 5;

		this.btn_send.disabled = true;
		this.btn_send.textContent = 'Sending…';

		const conversation = this.get_selected_conversation();
		const context = [];

		if (conversation && conversation[this.#storage.KEY_CONVERSATION_HISTORY]) {
			const history = conversation[this.#storage.KEY_CONVERSATION_HISTORY];
			let fullValueCount = 0;
			let summaryCount = 0;
			let chainBroken = false;

			for (let i = history.length - 1; i >= 0; i--) {
				const item = history[i];
				const prompt = item.query;
				let reply = "";

				// If we haven't hit a chain break and we are within the full value limit
				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) {
					// Use summary if available, otherwise skip or use empty
					reply = item['summary'] || "";
					summaryCount++;
				} else {
					break;
				}

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

				// If this item was not chained, subsequent (older) items must be summaries
				if (item.chain === false) {
					chainBroken = true;
				}
			}
		}

		const query = this.prompt.value;
		const selected_conversation = this.#storage.get_selected_conversation();
		const app_defaults = this.#storage.get_app_defaults();
		const verbosity = selected_conversation?.[this.#storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
		const model_key = selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL] || 'basic';
		const meta_context = {};

		if (selected_conversation && selected_conversation[this.#storage.KEY_CONVERSATION_TOPICS]) {
			meta_context.topics = selected_conversation[this.#storage.KEY_CONVERSATION_TOPICS];
		}
		if (selected_conversation && selected_conversation[this.#storage.KEY_CONVERSATION_CONSIDERATIONS]) {
			meta_context.considerations = selected_conversation[this.#storage.KEY_CONVERSATION_CONSIDERATIONS];
		}

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

		this.#api.post(
			this.#api.format_data(
				query,
				context,
				verbosity,
				model_key,
				meta_context
			),
			(response) => this.send_success(response, query),
			(response) => this.send_failure(response)
		).then(() => {console.log('POST successful.')})
	}

	/**
	 * Processes a successful API response by updating the conversation history and global index.
	 * @param {Object} response The response object received from the API.
	 * @param {string} query The original user query that generated this response.
	 */
	send_success(response, query){
		this.btn_send.disabled = false;
		this.btn_send.textContent = 'Send';

		const config = this.#storage.get_app_config();
		const guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		if(!('title' in response) || !guid) return;

		const conversation = this.#storage.get_conversation(guid);
		const conversationHistory = conversation[this.#storage.KEY_CONVERSATION_HISTORY] || [];

		const initialHistoryLength = conversationHistory.length;

		conversation[this.#storage.KEY_CONVERSATION_TITLE] = response.conversationTitle;
		conversation[this.#storage.KEY_CONVERSATION_SUMMARY] = response.conversationSummary;
		conversation[this.#storage.KEY_CONVERSATION_TIMESTAMP] = new Date().getTime();

		response['query'] = query;

		conversationHistory.push(response);
		conversation[this.#storage.KEY_CONVERSATION_HISTORY] = conversationHistory;

		this.#storage.save_conversation(guid, conversation);
		this.#storage.update_app_index(guid, false);

		this.prompt.value = '';
		this.validate_prompt();
		this.resize_prompt_textarea(); // Reset textarea size after sending
	}

	/**
	 * Handles API failures by logging the error and resetting the send button state.
	 * @param {*} error The error data or object received from the failed request.
	 */
	send_failure(error){
		this.btn_send.disabled = false;
		this.btn_send.textContent = 'Send';
		this.validate_prompt();
		console.error('App Error:', error);
	}

	/**
	 * Renders the content for the conversation tab.
	 * @param {boolean} scroll_to_last Whether the view should automatically scroll to the newest item.
	 */
	render_conversation_tab(scroll_to_last = true) {
		const conversation = this.get_selected_conversation();
		const history = conversation ? conversation[this.#storage.KEY_CONVERSATION_HISTORY] : null;
		const app_defaults = this.#storage.get_app_defaults();

		if (!history || history.length === 0) {
			this.div_response.innerHTML = '';
			return;
		}

		const conversation_settings = {
			showSuggestedQueries: conversation?.[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] || app_defaults?.[this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES],
			showRelatedQueries: conversation?.[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] || app_defaults?.[this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES]
		};

		let full_html = '';
		history.forEach((data, index) => {
			if (index > 0) {
				full_html += '<hr class="hr-chat-response-divider"/>';
			}
			full_html += this.#conversation.format_history_item(data, index, conversation_settings);
		});

		this.div_response.innerHTML = full_html;

		document.querySelectorAll('code[class*="language-"] pre').forEach((el) => {
			// noinspection JSUnresolvedReference
			hljs.highlightElement(el);
		});
		// noinspection JSIgnoredPromiseFromCall
		this.updateMermaid();

		MathJax.typeset();

		this.div_response.querySelectorAll('.span-query-chip').forEach(chip => {
			chip.addEventListener('click', this._handle_query_chip_click.bind(this));
		});

		if (scroll_to_last) {
			this.scroll_to_last_item();
		}
	}

	/**
	 * Handles clicks on suggested/related query chips.
	 * @param {Event} e The click event.
	 * @private
	 */
	_handle_query_chip_click(e) {
		const query_text = e.target.textContent;
		this.prompt.value = query_text;
		this.resize_prompt_textarea();
		this.validate_prompt();

		const conversation = this.get_selected_conversation();
		const app_defaults = this.#storage.get_app_defaults();
		const auto_run = conversation?.[this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] || app_defaults?.[this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES];

		if (auto_run) {
			this.send();
		}
	}

	/**
	 * Renders the conversation history, processes syntax highlighting, and updates mermaid diagrams.
	 * @param {boolean} scroll_to_last Whether the view should automatically scroll to the newest item.
	 */
	on_conversation_updated(scroll_to_last = true) {
		this.validate_conversation_index_button();
		const conversation = this.get_selected_conversation();
		const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE] || this.#storage.CONVERSATION_TYPE_CHAT;

		const config = this.#storage.get_app_config();
		const show_archives = config[this.#storage.KEY_SHOW_ARCHIVES] || false;
		if (conversation && conversation[this.#storage.KEY_CONVERSATION_ARCHIVED] && !show_archives) {
			this.close_conversation();
			return;
		}

		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		this.apply_conversation_options();

		if (!selected_guid || !conversation) {
			this._reset_chat_view();
			return;
		}

		this.div_chat_empty.style.display = 'none';
		this.div_chat_empty_header.style.display = 'none';
		this.div_chat_ui_elements.forEach(el => el.style.display = '');
		this.div_options_chips.style.display = 'block';
		this.div_chat_title_bar.style.display = '';
		this.div_chat_prompt_container.style.display = '';

		const experimental_features_enabled = config[this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
		this._update_tab_visibility(this.state_active_tab, type, experimental_features_enabled);

		this.div_chat_memory_scroll.classList.remove('chat-memory-style', 'notebook-memory-style');
		if (type === this.#storage.CONVERSATION_TYPE_CHAT) {
			this._setup_chat_conversation_ui();
		} else { // NOTEBOOK
			this._setup_notebook_conversation_ui();
		}

		const app_defaults = this.#storage.get_app_defaults();
		const verbosity = conversation?.[this.#storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
		const model_key = conversation?.[this.#storage.KEY_CONVERSATION_MODEL] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL] || 'basic';
		const model_name = Api.MODELS[model_key]?.name || model_key;

		this.span_option_model.innerHTML = '<span style="opacity: 0.5">Model:</span> '+model_name;
		this.span_option_verbosity.innerHTML = '<span style="opacity: 0.5">Verbosity:</span> '+verbosity;

		const show_suggested = conversation?.[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] || app_defaults?.[this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES];
		const show_related = conversation?.[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] || app_defaults?.[this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES];
		const enforce_topics = conversation?.[this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS] || app_defaults?.[this.#storage.KEY_CONFIG_ENFORCE_TOPICS];
		const auto_run = conversation?.[this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] || app_defaults?.[this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES];

		this.span_option_suggested.innerHTML = `<span style="opacity: 0.5">Suggested:</span> ${show_suggested ? 'ON' : 'OFF'}`;
		this.span_option_related.innerHTML = `<span style="opacity: 0.5">Related:</span> ${show_related ? 'ON' : 'OFF'}`;
		this.span_option_enforce_topics.innerHTML = `<span style="opacity: 0.5">Enforce Topics:</span> ${enforce_topics ? 'ON' : 'OFF'}`;
		this.span_option_auto_run.innerHTML = `<span style="opacity: 0.5">Auto-Run:</span> ${auto_run ? 'ON' : 'OFF'}`;

		this.render_conversation_header();
		// Synchronize the rotated class on span_subtitle_toggle with the subtitle-shown class on div_subtitle
		const is_subtitle_shown = this.div_subtitle.classList.contains('subtitle-shown');
		this.span_subtitle_toggle.classList.toggle('rotated', is_subtitle_shown);

		if (this.state_active_tab === 'conversation') {
			this.render_conversation_tab(scroll_to_last);
		} else if (this.state_active_tab === 'context') {
			this.render_context_tab();
		} else if (this.state_active_tab === 'memory' || this.state_active_tab === 'notebook-memory') {
			this.render_memory_tab();
		} else if (this.state_active_tab === 'document') {
			this.render_document_tab();
		} else if (this.state_active_tab === 'diction') {
			this.render_diction_tab();
		}
	}

	//endregion

	//region Utilities / Helpers

	/**
	 * Shows the application options form.
	 */
	show_app_options() {
		this._show_options('app');
	}

	/**
	 * Shows the conversation options form.
	 */
	show_conversation_options() {
		this._show_options('conversation');
	}

	/**
	 * Hides all options forms and the options overlay.
	 */
	hide_options_overlay() {
		if (this.div_options_overlay) {
			this.div_options_overlay.style.display = 'none';
		}
		if (this.form_app_options) {
			this.form_app_options.style.display = 'none';
		}
		if (this.form_conversation_options) {
			this.form_conversation_options.style.display = 'none';
		}
		if (this.form_account_options) {
			this.form_account_options.style.display = 'none';
		}
	}

	/**
	 * Handles click events on the options overlay, closing it if the click is on the overlay itself.
	 * @param {MouseEvent} event The mouse event object.
	 */
	handle_options_overlay_click(event) {
		if (event.target === this.div_options_overlay) {
			this.hide_options_overlay();
		}
	}

	/**
	 * Triggers the Mermaid library to render all mermaid diagram elements present in the DOM.
	 * @returns {Promise<void>}
	 */
	async updateMermaid() {
		if (typeof mermaid !== 'undefined') {
			mermaid.initialize({
				securityLevel: 'loose',
				theme: 'dark',
			});
			// noinspection JSUnresolvedReference
			await mermaid.run({
				querySelector: '.div-diagram-mermaid',
			});
		} else {
			console.log('No mermaid found.');
		}
	}

	/**
	 * Applies verbosity and model settings to the UI for a given level.
	 * @param {('conversation'|'app')} level - The level to apply options for.
	 * @private
	 */
	_apply_options(level) {
		const is_app = level === 'app';
		const app_defaults = this.#storage.get_app_defaults();

		// Start with app defaults
		let verbosity = app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
		let model_key = app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL] || 'basic';
		let show_suggestions = app_defaults?.[this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] || false;
		let show_related = app_defaults?.[this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES] || false;
		let enforce_topics = app_defaults?.[this.#storage.KEY_CONFIG_ENFORCE_TOPICS] || false;
		let auto_send_prompts = app_defaults?.[this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] || false;

		// If it's for a conversation, override with conversation-specific settings if they exist
		if (!is_app) {
			const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
			if (selected_guid) {
				const conversation = this.#storage.get_conversation(selected_guid);
				if (conversation) {
					verbosity = conversation[this.#storage.KEY_CONVERSATION_VERBOSITY] || verbosity;
					model_key = conversation[this.#storage.KEY_CONVERSATION_MODEL] || model_key;
					show_suggestions = conversation[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] || show_suggestions;
					show_related = conversation[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] || show_related;
					enforce_topics = conversation[this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS] || enforce_topics;
					auto_send_prompts = conversation[this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] || auto_send_prompts;
				}
			}
		}

		const verbosity_select = is_app ? this.select_default_verbosity : this.select_conversation_verbosity;
		const model_select = is_app ? this.select_default_model : this.select_conversation_model;
		const checkbox_show_suggestions = is_app ? this.checkbox_default_show_suggestions : this.checkbox_conversation_show_suggestions;
		const checkbox_show_related = is_app ? this.checkbox_default_show_related : this.checkbox_conversation_show_related;
		const checkbox_enforce_topics = is_app ? this.checkbox_default_enforce_topics : this.checkbox_conversation_enforce_topics;
		const checkbox_auto_send_prompts = is_app ? this.checkbox_default_auto_send_prompts : this.checkbox_conversation_auto_send_prompts;

		const valid_verbosity_options = ['minimal', 'standard', 'thorough', 'detailed'];
		if (!valid_verbosity_options.includes(verbosity)) {
			verbosity = 'standard';
		}
		verbosity_select.value = verbosity;

		if (Object.keys(Api.MODELS).length > 0 && !Api.MODELS.hasOwnProperty(model_key)) {
			model_key = 'basic';
		}
		model_select.value = model_key;

		checkbox_show_suggestions.checked = show_suggestions;
		checkbox_show_related.checked = show_related;
		checkbox_enforce_topics.checked = enforce_topics;
		checkbox_auto_send_prompts.checked = auto_send_prompts;
	}

	/**
	 * Saves the selected verbosity and model from the selectors for a given level.
	 * @param {('conversation'|'app')} level - The level to update settings for.
	 * @param {Event} e - The change event from the select element.
	 * @private
	 */
	_update_setting(level, e) {
		const is_conversation = level === 'conversation';
		const target_id = e.target.id;
		let key;
		let value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;

		if (is_conversation) {
			if (target_id === 'id-select-conversation-verbosity') {
				key = this.#storage.KEY_CONVERSATION_VERBOSITY;
			} else if (target_id === 'id-select-conversation-model') {
				key = this.#storage.KEY_CONVERSATION_MODEL;
			} else if (target_id === 'id-checkbox-conversation-show-suggestions') {
				key = this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES;
			} else if (target_id === 'id-checkbox-conversation-show-related') {
				key = this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES;
			} else if (target_id === 'id-checkbox-conversation-enforce-topics') {
				key = this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS;
			} else if (target_id === 'id-checkbox-conversation-auto-send-prompts') {
				key = this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES;
			}
		} else { // app
			if (target_id === 'id-select-default-verbosity') {
				key = this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY;
			} else if (target_id === 'id-select-default-model') {
				key = this.#storage.KEY_CONFIG_DEFAULT_MODEL;
			} else if (target_id === 'id-checkbox-default-show-suggestions') {
				key = this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES;
			} else if (target_id === 'id-checkbox-default-show-related') {
				key = this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES;
			} else if (target_id === 'id-checkbox-default-enforce-topics') {
				key = this.#storage.KEY_CONFIG_ENFORCE_TOPICS;
			} else if (target_id === 'id-checkbox-default-auto-send-prompts') {
				key = this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES;
			}
		}

		if (key) {
			if (is_conversation) {
				const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
				if (selected_guid) {
					this.#storage.update_conversation_field(selected_guid, key, value);
				}
			} else {
				this.#storage.update_app_defaults(key, value);
			}
		}
	}

	/**
	 * Toggles a boolean conversation option and updates the UI.
	 * @param {string} key The storage key for the conversation option.
	 * @private
	 */
	_toggle_conversation_option(key) {
		const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (selected_guid) {
			const conversation = this.#storage.get_conversation(selected_guid);
			const current_value = conversation[key];
			this.#storage.update_conversation_field(selected_guid, key, !current_value);
		}
	}

	/**
	 * Shows a specific options form and hides others.
	 * @param {('app'|'conversation')} form_type - The type of form to show.
	 * @private
	 */
	_show_options(form_type) {
		if (!this.div_options_overlay) return;

		const is_app = form_type === 'app';

		this.div_options_overlay.style.display = 'block';

		if (this.form_app_options) {
			this.form_app_options.style.display = is_app ? 'block' : 'none';
		}
		if (this.form_conversation_options) {
			this.form_conversation_options.style.display = is_app ? 'none' : 'block';
		}
		if (this.form_account_options) {
			this.form_account_options.style.display = is_app ? 'block' : 'none';
		}

		if (is_app) {
			this.apply_app_options();
			this.apply_app_defaults();
			const config = this.#storage.get_app_config();
			this.checkbox_experimental_features.checked = config[this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
		} else {
			this.apply_conversation_options();
		}
	}

	/**
	 * Resets the chat view to its empty state when no conversation is selected.
	 * @private
	 */
	_reset_chat_view() {
		this.div_chat_empty.style.display = 'grid';
		this.div_chat_empty_header.style.display = 'block';
		this.div_chat_ui_elements.forEach(el => el.style.display = 'none');
		this.div_options_chips.style.display = 'none';
		this.div_title.innerHTML = '';
		this.div_subtitle.innerHTML = '';
		this.div_subtitle.style.maxHeight = null;
		this.span_subtitle_toggle.classList.remove('rotated');
		this.div_chat_prompt_container.style.display = 'none';
		this.div_chat_title_bar.style.display = 'none';
		this._update_tab_visibility(null, null, false); // No active tab, no conversation type, experimental features off
		this.div_chat_prompt_inset.style.display = 'none';
	}

	/**
	 * Debounces a function to limit the rate at which it gets called.
	 * @param {Function} func The function to debounce.
	 * @param {number} delay The debounce delay in milliseconds.
	 * @returns {Function} The debounced function.
	 */
	debounce(func, delay) {
		return (...args) => {
			clearTimeout(this.resize_timeout);
			this.resize_timeout = setTimeout(() => func.apply(this, args), delay);
		};
	}

	/**
	 * This method should encapsulate all logic for showing/hiding tab buttons (btn_tab_conversation, btn_tab_context, etc.)
	 * and their corresponding scroll containers (div_chat_container_scroll, div_chat_context_scroll, etc.)
	 * Integrate the experimental_features_enabled logic for tab visibility into _update_tab_visibility().
	 * @param {string|null} activeTabName - The name of the currently active tab.
	 * @param {string|null} conversationType - The type of the current conversation (chat or notebook).
	 * @param {boolean} experimentalFeaturesEnabled - Whether experimental features are enabled.
	 * @private
	 */
	_update_tab_visibility(activeTabName, conversationType, experimentalFeaturesEnabled) {
		const tabs = {
			conversation: { btn: this.btn_tab_conversation, scroll: this.div_chat_container_scroll, type: 'chat' },
			context: { btn: this.btn_tab_context, scroll: this.div_chat_context_scroll, type: 'chat' },
			memory: { btn: this.btn_tab_memory, scroll: this.div_chat_memory_scroll, type: 'chat', experimental: true },
			document: { btn: this.btn_tab_document, scroll: this.div_chat_document_scroll, type: 'notebook' },
			diction: { btn: this.btn_tab_diction, scroll: this.div_chat_diction_scroll, type: 'notebook' },
			'notebook-memory': { btn: this.btn_tab_notebook_memory, scroll: this.div_notebook_memory_scroll, type: 'notebook', experimental: true }
		};

		// Hide all tab buttons and scroll containers initially
		for (const key in tabs) {
			if (tabs[key].btn) {
				tabs[key].btn.style.display = 'none';
				tabs[key].btn.classList.remove('active');
			}
			if (tabs[key].scroll) {
				tabs[key].scroll.style.display = 'none';
			}
		}

		this.div_chat_tab_bar.style.display = 'none';
		this.div_notebook_tab_bar.style.display = 'none';
		this.div_chat_prompt_container.style.display = 'none';

		if (!activeTabName || !conversationType) {
			return; // No conversation selected or type, so all tabs remain hidden
		}

		// Show relevant tab bars and buttons based on conversation type
		if (conversationType === this.#storage.CONVERSATION_TYPE_CHAT) {
			this.div_chat_tab_bar.style.display = '';
			tabs.conversation.btn.style.display = 'inline-block';
			tabs.context.btn.style.display = 'inline-block';
			if (experimentalFeaturesEnabled) {
				tabs.memory.btn.style.display = 'inline-block';
			}
		} else if (conversationType === this.#storage.CONVERSATION_TYPE_NOTEBOOK) {
			this.div_notebook_tab_bar.style.display = '';
			tabs.document.btn.style.display = 'inline-block';
			tabs.diction.btn.style.display = 'inline-block';
			if (experimentalFeaturesEnabled) {
				tabs['notebook-memory'].btn.style.display = 'inline-block';
			}
		}

		// Set active tab and show its content
		const currentTab = tabs[activeTabName];
		if (currentTab) {
			// Ensure the active tab is visible based on conversation type and experimental features
			const is_visible_by_type = currentTab.type === (conversationType === this.#storage.CONVERSATION_TYPE_CHAT ? 'chat' : 'notebook');
			const is_visible_by_experimental = !currentTab.experimental || experimentalFeaturesEnabled;

			if (is_visible_by_type && is_visible_by_experimental) {
				if (currentTab.btn) {
					currentTab.btn.classList.add('active');
				}
				if (currentTab.scroll) {
					currentTab.scroll.style.display = 'grid';
				}
			} else {
				// If the requested active tab is not valid for the current context,
				// default to 'conversation' for chat or 'document' for notebook.
				if (conversationType === this.#storage.CONVERSATION_TYPE_CHAT) {
					this.state_active_tab = 'conversation';
					tabs.conversation.btn.classList.add('active');
					tabs.conversation.scroll.style.display = 'grid';
				} else if (conversationType === this.#storage.CONVERSATION_TYPE_NOTEBOOK) {
					this.state_active_tab = 'document';
					tabs.document.btn.classList.add('active');
					tabs.document.scroll.style.display = 'grid';
				}
			}
		}

		const tabsWithPrompt = ['conversation', 'document'];
		this.div_chat_prompt_container.style.display = tabsWithPrompt.includes(this.state_active_tab) ? 'grid' : 'none';
	}

	_setup_chat_conversation_ui() {
		this.div_chat_tab_bar.style.display = '';
		this.div_notebook_tab_bar.style.display = 'none';
		this.div_index_list.style.display = '';
		this.div_structure_center_index_options.style.display = 'grid';
		this.div_structure_center_notebook_options.style.display = 'none';
		this.#conversation_index.on_conversation_index_updated();
		this.div_chat_memory_scroll.classList.add('chat-memory-style');
	}

	_setup_notebook_conversation_ui() {
		this.div_chat_tab_bar.style.display = 'none';
		this.div_notebook_tab_bar.style.display = '';
		this.div_index_list.style.display = 'none';
		this.div_structure_center_index_options.style.display = 'none';
		this.div_structure_center_notebook_options.style.display = 'grid';
		this.#notebook_index.render(this.div_index_list);
		this.div_chat_memory_scroll.classList.add('notebook-memory-style');
	}

}

export default App;
🌐
app.js ×
Type: Web, text/plain
58.50 Kilobytes
Last Modified 2026-05-04 01:41:58
⬇ Download File