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 Users from './users.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';
import ScratchpadIndex from './scratchpad-index.js';
import ScratchpadConversation from './scratchpad-conversation.js';
import { getEl, queryEl, queryAll, setElementDisplay, toggleElementClass, addSafeEventListener } from './util/dom-utils.js';
import { debounce, copyToClipboard } from './util/async-utils.js';
import { customConfirm, customAlert } from './util/confirm-dialog.js';
import { applyTheme } from './util/theme-utils.js';
import {
formatThinkingLevelLabel,
populatePresetDropdown,
populateFamilyDropdown,
populateModelVersionDropdown,
populateThinkingLevelDropdown,
findMatchingPreset,
formatModelFullName,
formatModelShortName,
updateModelDetailsDisplay
} from './util/settings-utils.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();
#abortController = null;
#audioCtx = null;
#wakeLock = null;
#scrollTimeout = null;
#conversation = new Conversation();
#context;
#memory = new Memory();
#notebook_index;
#document = new Document();
#diction = new Diction();
#scratchpad_index;
#scratchpad_conversation = new ScratchpadConversation();
#storage;
#conversations_list;
#conversation_index;
btn_empty_new_chat;
btn_empty_new_notebook;
btn_empty_new_scratchpad;
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_default_model_family;
select_default_model_version;
select_default_thinking_level;
select_conversation_model;
select_conversation_model_family;
select_conversation_model_version;
select_conversation_model_level;
checkbox_experimental_features;
checkbox_background_keep_alive;
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;
checkbox_conversation_google_search;
div_conversation_google_search_container;
btn_conversation_new;
btn_conversation_list_new;
btn_select_conversations;
btn_archive_conversations;
btn_delete_conversations;
btn_archives_toggle;
btn_show_conversations_new;
btn_show_scratchpads_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_search;
span_option_enforce_topics;
span_option_auto_run;
div_options_overlay;
form_app_options;
form_profile_options;
form_conversation_options;
form_account_options;
form_admin_options;
form_add_funds;
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';
is_streaming_thinking = false;
//region Initialization
/**
* Initializes the application state, mermaid diagrams, UI elements, and event listeners.
* @param {Storage} storage - Storage manager instance.
*/
constructor(storage) {
this.#storage = storage;
const initial_config = this.#storage.get_app_config();
const initial_guid = initial_config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
const isScratchpadRoot = (initial_guid === 'scratchpad');
const initialConv = initial_guid && !isScratchpadRoot ? this.#storage.get_conversation(initial_guid) : null;
const hasHistory = initialConv && Array.isArray(initialConv[this.#storage.KEY_CONVERSATION_HISTORY]) && initialConv[this.#storage.KEY_CONVERSATION_HISTORY].length > 0;
const hasScratchpads = isScratchpadRoot && (this.get_scratchpad_count() > 0);
this.state_v_open = window.innerWidth > this.BREAKPOINT_MOBILE;
this.state_i_open = false;
if (typeof mermaid !== 'undefined') {
try {
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: 'dark',
suppressErrorRendering: true
});
} catch (e) {
console.error('Failed to initialize mermaid:', e);
}
}
this.init_elements();
this._populate_model_dropdowns();
this.init_conversations_list();
this.init_conversation_index();
this.init_notebook_index();
this.init_scratchpad_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 structured access.
*/
init_elements() {
this.btn_close = getEl('btn-close');
this.btn_costs = getEl('btn-costs');
this.btn_app_options = getEl('btn-app-options');
this.btn_conversation_options = getEl('id-btn-conversation-options');
this.btn_options_close = getEl('btn-options-close');
this.btn_send = getEl('btn-send');
this.prompt = getEl('id-prompt');
this.div_title = getEl('id-div-response-title');
this.div_subtitle = getEl('id-div-response-subtitle');
this.div_response = getEl('id-div-response-render');
this.select_theme = getEl('id-select-theme');
this.select_default_verbosity = getEl('id-select-default-verbosity');
this.select_conversation_verbosity = getEl('id-select-conversation-verbosity');
this.select_default_model = getEl('id-select-default-model');
this.select_default_model_family = getEl('id-select-default-model-family');
this.select_default_model_version = getEl('id-select-default-model-version');
this.select_default_thinking_level = getEl('id-select-default-thinking-level');
this.select_conversation_model = getEl('id-select-conversation-model');
this.select_conversation_model_family = getEl('id-select-conversation-model-family');
this.select_conversation_model_version = getEl('id-select-conversation-model-version');
this.select_conversation_model_level = getEl('id-select-conversation-model-level');
this.div_default_model_description = getEl('id-div-default-model-description');
this.div_default_model_costs = getEl('id-div-default-model-costs');
this.div_conversation_model_description = getEl('id-div-conversation-model-description');
this.div_conversation_model_costs = getEl('id-div-conversation-model-costs');
this.checkbox_experimental_features = getEl('id-checkbox-experimental-features');
this.checkbox_background_keep_alive = getEl('id-checkbox-background-keep-alive');
this.checkbox_default_show_suggestions = getEl('id-checkbox-default-show-suggestions');
this.checkbox_default_show_related = getEl('id-checkbox-default-show-related');
this.checkbox_default_enforce_topics = getEl('id-checkbox-default-enforce-topics');
this.checkbox_default_auto_send_prompts = getEl('id-checkbox-default-auto-send-prompts');
this.checkbox_default_play_chime = getEl('id-checkbox-default-play-chime');
this.checkbox_conversation_show_suggestions = getEl('id-checkbox-conversation-show-suggestions');
this.checkbox_conversation_show_related = getEl('id-checkbox-conversation-show-related');
this.checkbox_conversation_enforce_topics = getEl('id-checkbox-conversation-enforce-topics');
this.checkbox_conversation_auto_send_prompts = getEl('id-checkbox-conversation-auto-send-prompts');
this.checkbox_conversation_google_search = getEl('id-checkbox-conversation-google-search');
this.div_conversation_google_search_container = getEl('id-div-conversation-google-search-container');
this.div_list = getEl('id-div-list');
this.div_index_list = getEl('id-div-center-index-list');
this.btn_conversation_new = getEl('id-btn-conversation-new');
this.btn_conversation_list_new = getEl('id-btn-conversation-list-new');
this.btn_select_conversations = getEl('id-btn-select-conversations');
this.btn_archive_conversations = getEl('id-btn-archive-conversation');
this.btn_archives_toggle = getEl('id-btn-conversation-archives');
this.btn_delete_conversations = getEl('id-btn-delete-conversation');
this.btn_empty_new_chat = getEl('id-btn-empty-new-chat');
this.btn_empty_new_notebook = getEl('id-btn-empty-new-notebook');
this.btn_empty_new_scratchpad = getEl('id-btn-empty-new-scratchpad');
this.btn_show_conversations_new = getEl('id-btn-show-conversations-new');
this.btn_show_scratchpads_new = getEl('id-btn-show-scratchpads-new');
this.btn_conversations = getEl('id-btn-conversations');
this.btn_conversation_index = getEl('id-btn-conversation-index');
this.btn_conversation_index_select = getEl('id-btn-conversation-index-select');
this.btn_conversation_index_favorite = getEl('id-btn-conversation-index-favorite');
this.btn_conversation_index_delete = getEl('id-btn-conversation-index-delete');
this.div_chat_empty = queryEl('.div-chat-empty');
this.div_chat_empty_header = queryEl('.div-chat-empty-header');
this.div_chat_ui_elements = queryAll('.div-chat-ui-elements');
this.div_options_chips = queryEl('.div-options-chips');
this.span_option_model = getEl('id-span-option-model');
this.span_option_verbosity = getEl('id-span-option-verbosity');
this.span_option_suggested = getEl('id-span-option-suggested');
this.span_option_related = getEl('id-span-option-related');
this.span_option_search = getEl('id-span-option-search');
this.span_option_enforce_topics = getEl('id-span-option-enforce-topics');
this.span_option_auto_run = getEl('id-span-option-auto-run');
this.div_options_overlay = queryEl('.div-structure-dialog-overlay');
this.form_app_options = getEl('id-form-app-options');
this.form_profile_options = getEl('id-form-profile-options');
this.form_conversation_options = getEl('id-form-conversation-options');
this.form_account_options = getEl('id-form-account-options');
this.form_admin_options = getEl('id-form-admin-options');
this.form_add_funds = getEl('id-form-add-funds');
this.span_subtitle_toggle = getEl('id-span-subtitle-toggle');
this.btn_tab_conversation = getEl('id-btn-tab-conversation');
this.btn_tab_context = getEl('id-btn-tab-context');
this.btn_tab_memory = getEl('id-btn-tab-memory');
this.btn_tab_document = getEl('id-btn-tab-document');
this.btn_tab_diction = getEl('id-btn-tab-diction');
this.btn_tab_notebook_memory = getEl('id-btn-tab-notebook-memory');
this.div_chat_container_scroll = getEl('id-chat-container-scroll');
this.div_chat_context_scroll = getEl('div-chat-context-scroll');
this.div_chat_memory_scroll = getEl('div-chat-memory-scroll');
this.div_notebook_memory_scroll = getEl('div-notebook-memory-scroll');
this.div_chat_document_scroll = getEl('div-chat-document-scroll');
this.div_chat_diction_scroll = getEl('div-chat-diction-scroll');
this.div_chat_prompt_inset = getEl('div-chat-prompt-inset');
this.div_chat_prompt_container = getEl('div-chat-prompt-container');
this.div_chat_title_bar = queryEl('.div-chat-title-bar');
this.div_chat_tab_bar = queryEl('.div-chat-tab-bar');
this.div_notebook_tab_bar = queryEl('.div-notebook-tab-bar');
this.div_structure_center_notebook_options = getEl('div-structure-center-notebook-options');
this.div_structure_center_index_options = queryEl('.div-structure-center-index-options');
this._reset_chat_view();
}
/**
* Populates the model dropdowns with options from Api.MODELS and Api.PRESETS.
* @private
*/
_populate_model_dropdowns() {
const models = Api.MODELS;
const presets = Api.PRESETS;
populatePresetDropdown(this.select_default_model, presets);
populatePresetDropdown(this.select_conversation_model, presets);
populateFamilyDropdown(this.select_default_model_family, models);
populateFamilyDropdown(this.select_conversation_model_family, models);
}
/**
* 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,
BREAKPOINT_TABLET: this.BREAKPOINT_TABLET
};
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);
}
/**
* Initializes the NotebookIndex instance.
*/
init_notebook_index() {
this.#notebook_index = new NotebookIndex(this.#storage, {});
}
/**
* Initializes the ScratchpadIndex instance with callbacks and DOM elements.
*/
init_scratchpad_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),
close_conversation: this.close_conversation.bind(this),
update_app_config: (key, val) => this.#storage.update_app_config(key, val),
create_new_scratchpad: () => this.#conversations_list?.index_new?.(this.#storage.CONVERSATION_TYPE_SCRATCHPAD)
};
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.#scratchpad_index = new ScratchpadIndex(this.#storage, app_callbacks, elements, breakpoints);
}
/**
* Initializes the Context instance.
*/
init_context() {
const app_callbacks = {
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) => {
this.handle_storage_change(event);
});
let lastWidth = window.innerWidth;
const debouncedResize = 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 = queryEl('.div-chat-container-scroll');
if (scrollContainer) {
scrollContainer.addEventListener('scroll', () => {
if (this.#conversation_index) {
this.#conversation_index.highlight_active_index_item();
}
});
}
}
/**
* Binds user interaction events such as clicks and keyboard inputs to their respective handlers.
*/
init_interactions() {
addSafeEventListener(this.btn_send, 'click', this.send.bind(this));
if (this.prompt) {
this.prompt.oninput = () => {
this.validate_prompt();
this.resize_prompt_textarea();
};
this.prompt.onkeydown = this.handle_keydown.bind(this);
}
addSafeEventListener(this.select_theme, 'change', (e) => this.update_app_options_setting(e));
addSafeEventListener(this.select_default_verbosity, 'change', (e) => this._update_setting('app', e));
addSafeEventListener(this.select_conversation_verbosity, 'change', (e) => this._update_setting('conversation', e));
addSafeEventListener(this.select_default_model, 'change', (e) => this._on_preset_change('app', e));
addSafeEventListener(this.select_conversation_model, 'change', (e) => this._on_preset_change('conversation', e));
addSafeEventListener(this.select_default_model_family, 'change', (e) => this._on_family_change('app', e));
addSafeEventListener(this.select_conversation_model_family, 'change', (e) => this._on_family_change('conversation', e));
addSafeEventListener(this.select_default_model_version, 'change', (e) => this._on_version_change('app', e));
addSafeEventListener(this.select_conversation_model_version, 'change', (e) => this._on_version_change('conversation', e));
addSafeEventListener(this.select_default_thinking_level, 'change', (e) => this._on_thinking_level_change('app', e));
addSafeEventListener(this.select_conversation_model_level, 'change', (e) => this._on_thinking_level_change('conversation', e));
addSafeEventListener(this.checkbox_experimental_features, 'change', (e) => this.update_experimental_features_setting(e));
addSafeEventListener(this.checkbox_background_keep_alive, 'change', (e) => this.update_background_keep_alive_setting(e));
addSafeEventListener(this.checkbox_default_show_suggestions, 'change', (e) => this._update_setting('app', e));
addSafeEventListener(this.checkbox_default_show_related, 'change', (e) => this._update_setting('app', e));
addSafeEventListener(this.checkbox_default_enforce_topics, 'change', (e) => this._update_setting('app', e));
addSafeEventListener(this.checkbox_default_auto_send_prompts, 'change', (e) => this._update_setting('app', e));
addSafeEventListener(this.checkbox_default_play_chime, 'change', (e) => this._update_setting('app', e));
addSafeEventListener(this.checkbox_conversation_show_suggestions, 'change', (e) => this._update_setting('conversation', e));
addSafeEventListener(this.checkbox_conversation_show_related, 'change', (e) => this._update_setting('conversation', e));
addSafeEventListener(this.checkbox_conversation_enforce_topics, 'change', (e) => this._update_setting('conversation', e));
addSafeEventListener(this.checkbox_conversation_auto_send_prompts, 'change', (e) => this._update_setting('conversation', e));
addSafeEventListener(this.checkbox_conversation_google_search, 'change', (e) => this._update_setting('conversation', e));
addSafeEventListener(this.btn_conversation_new, 'click', () => {
if (this.is_scratchpad_active()) {
this.#conversations_list?.index_new?.(this.#storage.CONVERSATION_TYPE_SCRATCHPAD);
} else {
this.#conversations_list?.index_new?.(this.#storage.CONVERSATION_TYPE_CHAT);
}
});
addSafeEventListener(this.btn_conversation_list_new, 'click', this.close_conversation.bind(this));
addSafeEventListener(this.btn_empty_new_chat, 'click', () => this.#conversations_list?.index_new?.(this.#storage.CONVERSATION_TYPE_CHAT));
addSafeEventListener(this.btn_empty_new_notebook, 'click', () => this.#conversations_list?.index_new?.(this.#storage.CONVERSATION_TYPE_NOTEBOOK));
addSafeEventListener(this.btn_empty_new_scratchpad, 'click', () => this.#conversations_list?.index_new?.(this.#storage.CONVERSATION_TYPE_SCRATCHPAD));
addSafeEventListener(this.btn_show_conversations_new, 'click', this.toggle_conversation_panel.bind(this));
addSafeEventListener(this.btn_show_scratchpads_new, 'click', () => {
if (window.innerWidth <= this.BREAKPOINT_TABLET) {
this.state_v_open = false;
this.state_i_open = true;
} else {
this.state_i_open = true;
}
this.apply_panels_layout();
this.#storage.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, 'scratchpad');
this.on_conversation_updated();
});
addSafeEventListener(this.btn_conversations, 'click', this.toggle_conversation_panel.bind(this));
addSafeEventListener(this.btn_conversation_index, 'click', this.toggle_conversation_index.bind(this));
addSafeEventListener(this.btn_close, 'click', this.close_conversation.bind(this));
addSafeEventListener(this.btn_select_conversations, 'click', () => this.#conversations_list?.toggle_conversation_selection_mode?.());
addSafeEventListener(this.btn_archive_conversations, 'click', () => this.#conversations_list?.archive_selected_conversations?.());
addSafeEventListener(this.btn_archives_toggle, 'click', () => this.#conversations_list?.toggle_archives?.());
addSafeEventListener(this.btn_delete_conversations, 'click', () => this.#conversations_list?.delete_selected_conversations?.());
addSafeEventListener(this.btn_conversation_index_select, 'click', () => {
if (this.is_scratchpad_active()) {
this.#scratchpad_index.toggle_response_selection_mode();
} else {
this.#conversation_index.toggle_response_selection_mode();
}
});
addSafeEventListener(this.btn_conversation_index_favorite, 'click', () => {
if (this.is_scratchpad_active()) {
this.#scratchpad_index.bookmark_selected_responses();
} else {
this.#conversation_index.bookmark_selected_responses();
}
});
addSafeEventListener(this.btn_conversation_index_delete, 'click', () => {
if (this.is_scratchpad_active()) {
this.#scratchpad_index.delete_selected_responses();
} else {
this.#conversation_index.delete_selected_responses();
}
});
addSafeEventListener(this.btn_app_options, 'click', this.show_app_options.bind(this));
addSafeEventListener(this.btn_conversation_options, 'click', this.show_conversation_options.bind(this));
addSafeEventListener(this.btn_options_close, 'click', this.hide_options_overlay.bind(this));
addSafeEventListener(this.div_options_overlay, 'click', this.handle_options_overlay_click.bind(this));
addSafeEventListener(this.span_subtitle_toggle, 'click', this.toggle_subtitle.bind(this));
addSafeEventListener(this.div_title, 'click', this.toggle_subtitle.bind(this));
addSafeEventListener(this.btn_tab_conversation, 'click', () => this.set_active_tab('conversation'));
addSafeEventListener(this.btn_tab_context, 'click', () => this.set_active_tab('context'));
addSafeEventListener(this.btn_tab_memory, 'click', () => this.set_active_tab('memory'));
addSafeEventListener(this.btn_tab_document, 'click', () => this.set_active_tab('document'));
addSafeEventListener(this.btn_tab_diction, 'click', () => this.set_active_tab('diction'));
addSafeEventListener(this.btn_tab_notebook_memory, 'click', () => this.set_active_tab('notebook-memory'));
addSafeEventListener(this.span_option_model, 'click', this.show_conversation_options.bind(this));
addSafeEventListener(this.span_option_verbosity, 'click', this.show_conversation_options.bind(this));
addSafeEventListener(this.span_option_suggested, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES));
addSafeEventListener(this.span_option_related, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES));
addSafeEventListener(this.span_option_search, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_GOOGLE_SEARCH));
addSafeEventListener(this.span_option_enforce_topics, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS));
addSafeEventListener(this.span_option_auto_run, 'click', () => 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 || !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 (this.is_streaming_thinking) return;
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;
if (this.btn_empty_new_notebook) {
this.btn_empty_new_notebook.style.display = experimental_features_enabled ? 'inline-block' : 'none';
}
const notebookRow = getEl('id-div-empty-notebook-row');
if (notebookRow) {
notebookRow.style.display = experimental_features_enabled ? 'flex' : '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.
* @param {Event} e - The change event.
*/
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);
}
/**
* Updates background keep alive setting in the application configuration.
* @param {Event} e - The change event from the checkbox.
*/
update_background_keep_alive_setting(e) {
this.#storage.update_app_config(this.#storage.KEY_CONFIG_BACKGROUND_KEEP_ALIVE, 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() {
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';
}
if (this.select_theme) {
this.select_theme.value = theme;
}
applyTheme(theme, 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 (this.prompt) {
if (currentWidth <= this.BREAKPOINT_MOBILE) {
this.prompt.placeholder = 'Ctrl + \u27a5 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 body = getEl('id-div-structure-body');
if (!body) return;
body.classList.remove('div-structure-body-v-i-c', 'div-structure-body-v-c', 'div-structure-body-i-c', '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 {
body.classList.add('div-structure-body-c');
}
if (this.btn_conversations) {
toggleElementClass(this.btn_conversations, 'underlined-button', !!this.state_v_open);
}
if (this.btn_conversation_index) {
toggleElementClass(this.btn_conversation_index, 'underlined-button', !!this.state_i_open);
}
}
//endregion
//region Conversation / Chat
/**
* Sets the active tab and updates the UI practicalities.
* @param {string} tabName - The name of the tab to activate.
*/
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);
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] && typeof tabs[tabName].render === 'function') {
tabs[tabName].render();
}
}
/**
* Renders the content for the context tab.
*/
render_context_tab() {
if (!this.div_chat_context_scroll || !this.#context) return;
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) {
if (this.div_chat_memory_scroll) {
this.div_chat_memory_scroll.innerHTML = this.#memory.render();
}
} else if (this.div_notebook_memory_scroll) {
this.div_notebook_memory_scroll.innerHTML = this.#memory.render();
}
}
/**
* Placeholder method for reformatting the active conversation.
*/
reformat_conversation() {
// Reserved for full conversation reformat if needed
}
/**
* Renders the content for the document tab and binds change listener.
*/
render_document_tab() {
if (!this.div_chat_document_scroll || !this.#document) return;
const conversation = this.get_selected_conversation();
const currentDocument = conversation ? conversation[this.#storage.KEY_CONVERSATION_DOCUMENT] || '' : '';
this.div_chat_document_scroll.innerHTML = this.#document.render(currentDocument);
this.#document.attachEventListeners((newText) => {
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, this.#storage.KEY_CONVERSATION_DOCUMENT, newText);
}
});
}
/**
* Renders the content for the diction tab and binds change listener.
*/
render_diction_tab() {
if (!this.div_chat_diction_scroll || !this.#diction) return;
const conversation = this.get_selected_conversation();
const currentDiction = conversation ? conversation[this.#storage.KEY_CONVERSATION_DICTION] || '' : '';
this.div_chat_diction_scroll.innerHTML = this.#diction.render(currentDiction);
this.#diction.attachEventListeners((newDiction) => {
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, this.#storage.KEY_CONVERSATION_DICTION, newDiction);
}
});
}
/**
* 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) {
for (let i = history.length - 1; i >= 0; i--) {
if (history[i] && (history[i].title || history[i].conversationTitle || history[i]['summary'] || history[i]['conversationSummary'])) {
title = history[i].title || history[i].conversationTitle || title;
summary = history[i]['summary'] || history[i]['conversationSummary'] || summary;
break;
}
}
}
if (this.div_title) {
let h4_title = this.div_title.querySelector('h4');
if (h4_title) {
h4_title.innerHTML = title;
} else {
this.div_title.insertAdjacentHTML('afterbegin', `<h4>${title}</h4>`);
}
}
if (this.div_subtitle) {
let h6_subtitle = this.div_subtitle.querySelector('h6');
if (h6_subtitle) {
h6_subtitle.innerHTML = summary;
} else {
this.div_subtitle.insertAdjacentHTML('afterbegin', `<h6>${summary}</h6>`);
}
}
}
/**
* Toggles the visibility of the subtitle and rotates the toggle icon.
*/
toggle_subtitle() {
if (!this.div_subtitle) return;
const is_shown = this.div_subtitle.classList.toggle('subtitle-shown');
if (this.span_subtitle_toggle) {
this.span_subtitle_toggle.classList.toggle('rotated', is_shown);
}
this.div_subtitle.style.maxHeight = is_shown ? this.div_subtitle.scrollHeight + 'px' : null;
}
/**
* 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.#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;
const lastItem = history[lastIndex];
this.#scrollTimeout = setTimeout(() => {
this.#scrollTimeout = null;
if (lastItem && lastItem.pending) {
const container = this.div_chat_container_scroll;
if (container) {
container.scrollTop = container.scrollHeight;
}
} else {
const lastEl = getEl('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;
this.state_v_open = window.innerWidth > this.BREAKPOINT_MOBILE;
this.#storage.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, '');
this.apply_panels_layout();
}
/**
* Returns the count of scratchpad conversations in the index.
* @returns {number} The count of scratchpad conversations.
*/
get_scratchpad_count() {
const index = this.#storage.get_app_index();
let count = 0;
(index || []).forEach(item => {
const guid = item[this.#storage.KEY_INDEX_GUID];
const conversation = this.#storage.get_conversation(guid);
const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE] || item?.[this.#storage.KEY_CONVERSATION_TYPE];
if (type === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) {
count++;
}
});
return count;
}
/**
* Validates the state of the conversation index button.
*/
validate_conversation_index_button() {
if (!this.btn_conversation_index) return;
if (this.is_scratchpad_active()) {
this.btn_conversation_index.textContent = 'Scratchpads';
const count = this.get_scratchpad_count();
this.btn_conversation_index.disabled = (count === 0);
if (count === 0 && this.state_i_open) {
this.state_i_open = false;
this.apply_panels_layout();
}
} else {
this.btn_conversation_index.textContent = 'History';
const count = this.get_conversation_item_count();
this.btn_conversation_index.disabled = (count === 0);
if (count === 0 && this.state_i_open) {
this.state_i_open = false;
this.apply_panels_layout();
}
}
}
/**
* Returns the number of responses in the currently selected conversation.
* @returns {number} The count of history items.
*/
get_conversation_item_count() {
const config = this.#storage.get_app_config();
const guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (guid) {
const conversation = this.#storage.get_conversation(guid);
if (conversation && conversation[this.#storage.KEY_CONVERSATION_HISTORY]) {
return conversation[this.#storage.KEY_CONVERSATION_HISTORY].length;
}
}
return 0;
}
/**
* Checks whether the prompt textarea contains valid text.
* @returns {boolean} True if prompt is non-empty, false process otherwise.
*/
validate_prompt() {
if (!this.btn_send) return false;
if (this.prompt && this.prompt.value && this.prompt.value.trim().length > 0) {
this.btn_send.disabled = false;
return true;
} else {
this.btn_send.disabled = true;
return false;
}
}
/**
* Adjusts the height of the prompt textarea to match its content.
*/
resize_prompt_textarea() {
if (!this.prompt) return;
this.prompt.style.height = 'auto';
this.prompt.style.height = this.prompt.scrollHeight + 'px';
}
/**
* Prepares the payload and initiates sending a prompt to the API.
*/
send() {
if (!this.validate_prompt()) {
return;
}
if (this.btn_send) {
this.btn_send.disabled = true;
this.btn_send.textContent = '...';
}
const query = this.prompt.value;
const config = this.#storage.get_app_config();
let guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (!guid) {
guid = this.#storage.update_app_index('', true);
this.#storage.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, guid);
}
const context = [];
if (guid) {
const conversation = this.#storage.get_conversation(guid);
let conversationHistory = conversation[this.#storage.KEY_CONVERSATION_HISTORY] || [];
const wasEmpty = (conversationHistory.filter(item => !item.pending).length === 0);
conversationHistory = conversationHistory.filter(item => !item.pending && this._has_valid_content(item));
conversationHistory.push({
query: query,
pending: true,
thinking: []
});
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = conversationHistory;
this.#storage.save_conversation(guid, conversation);
if (wasEmpty && window.innerWidth > this.BREAKPOINT_MOBILE) {
this.state_i_open = true;
if (window.innerWidth <= this.BREAKPOINT_TABLET) {
this.state_v_open = false;
}
this.apply_panels_layout();
}
this.on_conversation_updated();
const history = conversation[this.#storage.KEY_CONVERSATION_HISTORY];
if (history && history.length > 1) {
const isScratchpad = conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD;
const max_full_values = isScratchpad ? 1 : 3;
const max_summaries = isScratchpad ? 0 : 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.#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 family = selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
const model_version = selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL_VERSION] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
const thinking_level = (selected_conversation && selected_conversation[this.#storage.KEY_CONVERSATION_THINKING_LEVEL] !== undefined)
? selected_conversation[this.#storage.KEY_CONVERSATION_THINKING_LEVEL]
: (app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL] ?? 'MINIMAL');
const has_explicit_model = !!(selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL_FAMILY] || selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL_VERSION]);
let model_key = selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL];
if (model_key === undefined || model_key === null || model_key === '') {
model_key = has_explicit_model ? (model_version || '') : (app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL] || '');
}
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];
}
if (selected_conversation && selected_conversation[this.#storage.KEY_CONVERSATION_GOOGLE_SEARCH]) {
meta_context.google_search = selected_conversation[this.#storage.KEY_CONVERSATION_GOOGLE_SEARCH];
}
meta_context.enforce_topics = selected_conversation?.[this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS] || app_defaults?.[this.#storage.KEY_CONFIG_ENFORCE_TOPICS] || false;
this.#abortController = new AbortController();
this.show_progress_ui();
void this.start_background_keep_alive();
const isScratchpad = selected_conversation && selected_conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD;
let function_name = 'chat';
if (isScratchpad) {
function_name = 'chat&scratchpad=true';
}
const payload = this.#api.format_data(
query,
context,
verbosity,
model_key,
meta_context,
false,
{ family, model: model_version, thinking: thinking_level }
);
if (isScratchpad) {
payload.scratchpad = true;
}
void this.#api.post(
payload,
(response) => {
this.hide_progress_ui();
this.send_success(response, query);
},
(response) => {
this.hide_progress_ui();
this.send_failure(response);
},
function_name,
(chunk) => this.send_thinking(chunk),
(bytes) => this.update_progress(bytes),
this.#abortController.signal
);
}
/**
* Displays the progress indicator UI during request processing.
*/
show_progress_ui() {
setElementDisplay(getEl('div-prompt-input'), 'none');
setElementDisplay(getEl('div-prompt-clarification'), 'none');
const progressDiv = getEl('div-response-progress');
if (!progressDiv) return;
progressDiv.innerHTML = '';
progressDiv.style.display = 'block';
const text = document.createElement('span');
text.id = 'span-progress-text';
text.textContent = 'Processing response...';
progressDiv.appendChild(text);
const stopBtn = document.createElement('button');
stopBtn.textContent = 'Stop';
stopBtn.className = 'as-icon';
stopBtn.style.float = 'right';
stopBtn.onclick = () => this.stop_response();
progressDiv.appendChild(stopBtn);
}
/**
* Updates the response progress label with received byte count.
* @param {number} bytes - Bytes received.
*/
update_progress(bytes) {
const text = getEl('span-progress-text');
if (text) {
text.textContent = `Processing response... (${bytes})`;
}
}
/**
* 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');
}
/**
* Acquires a screen wake lock and initiates silent audio context to prevent background throttling.
* @returns {Promise<void>}\\n\t */
async start_background_keep_alive() {
try {
if ('wakeLock' in navigator) {
this.#wakeLock = await navigator.wakeLock.request('screen');
}
} catch (err) {
console.warn('Wake Lock request failed:', err);
}
const config = this.#storage.get_app_config();
const keep_alive_enabled = config[this.#storage.KEY_CONFIG_BACKGROUND_KEEP_ALIVE] || false;
if (!keep_alive_enabled) return;
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.
*/
stop_background_keep_alive() {
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 and resets progress UI.
*/
stop_response() {
if (this.#abortController) {
this.#abortController.abort();
this.#abortController = null;
}
this.is_streaming_thinking = false;
this.hide_progress_ui();
this.stop_background_keep_alive();
if (this.btn_send) {
this.btn_send.disabled = false;
this.btn_send.textContent = 'Send';
}
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 history = conversation[this.#storage.KEY_CONVERSATION_HISTORY];
if (history && history.length > 0 && history[history.length - 1].pending) {
history.pop();
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
this.#storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated();
}
}
}
/**
* Appends streamed thinking chunks to the currently pending history item.
* @param {string} chunk - Streamed thinking text snippet.
*/
send_thinking(chunk) {
this.is_streaming_thinking = true;
const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (!selected_guid) return;
const conversation = this.#storage.get_conversation(selected_guid);
const history = conversation[this.#storage.KEY_CONVERSATION_HISTORY];
if (!history || history.length === 0) return;
const lastItem = history[history.length - 1];
if (!lastItem.pending) return;
if (!lastItem.thinking) {
lastItem.thinking = [];
}
lastItem.thinking.push(chunk);
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
this.#storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated();
}
/**
* Callback handling API execution failure or cancellation.
* @param {Object} response - The API error response.
*/
send_failure(response) {
this.is_streaming_thinking = false;
this.stop_background_keep_alive();
if (this.btn_send) {
this.btn_send.disabled = false;
this.btn_send.textContent = 'Send';
}
if (response && response.status === 'aborted') {
return;
}
if (this.prompt) {
this.prompt.value = '';
this.resize_prompt_textarea();
}
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 history = conversation[this.#storage.KEY_CONVERSATION_HISTORY];
if (history && history.length > 0 && history[history.length - 1].pending) {
history.pop();
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
this.#storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated();
}
}
void customAlert('Error', response?.error || 'Something went wrong while communicating with the server.');
}
/**
* Plays a subtle sound alert upon receiving a completion response.
*/
play_alert_sound() {
const app_defaults = this.#storage.get_app_defaults();
const play_chime = app_defaults?.[this.#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);
}
}
/**
* Processes successful API responses, updates state, and renders the updated conversation.
* @param {Object} response - The API response object.
* @param {string} originalQuery - The original user prompt query.
*/
send_success(response, originalQuery) {
this.is_streaming_thinking = false;
this.stop_background_keep_alive();
this.play_alert_sound();
if (this.btn_send) {
this.btn_send.disabled = false;
this.btn_send.textContent = 'Send';
}
if (this.prompt) {
this.prompt.value = '';
this.resize_prompt_textarea();
}
const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (!selected_guid) return;
let conversation = this.#storage.get_conversation(selected_guid);
let history = conversation[this.#storage.KEY_CONVERSATION_HISTORY];
let pendingThinking = null;
if (history && history.length > 0 && history[history.length - 1].pending) {
const pendingItem = history.pop();
if (pendingItem && pendingItem.thinking) {
pendingThinking = pendingItem.thinking;
}
}
let isScratchpad = (conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD);
if (!response.thinking && pendingThinking) {
response.thinking = pendingThinking;
}
if (isScratchpad) {
history = [];
conversation[this.#storage.KEY_CONVERSATION_TITLE] = response.title || response.conversationTitle || 'Scratchpad';
conversation[this.#storage.KEY_CONVERSATION_SUMMARY] = response.summary || response.conversationSummary || '';
response.query = originalQuery;
history.push(response);
} else {
if (response.title || response.conversationTitle) {
conversation[this.#storage.KEY_CONVERSATION_TITLE] = response.title || response.conversationTitle;
}
if (response.summary || response.conversationSummary) {
conversation[this.#storage.KEY_CONVERSATION_SUMMARY] = response.summary || response.conversationSummary;
}
response.query = originalQuery;
history.push(response);
}
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
if (response.topics) {
conversation[this.#storage.KEY_CONVERSATION_TOPICS] = response.topics;
}
if (response.considerations) {
conversation[this.#storage.KEY_CONVERSATION_CONSIDERATIONS] = response.considerations;
}
this.#storage.save_conversation(selected_guid, conversation);
this.#storage.update_app_index(selected_guid);
const wasFirstCompletedEntry = (!isScratchpad && history.length === 1);
if (wasFirstCompletedEntry && window.innerWidth > this.BREAKPOINT_MOBILE && !this.state_i_open) {
this.state_i_open = true;
if (window.innerWidth <= this.BREAKPOINT_TABLET) {
this.state_v_open = false;
}
this.apply_panels_layout();
}
this.on_conversation_updated();
const app_defaults = this.#storage.get_app_defaults();
const auto_run = (conversation && conversation[this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES]
: (app_defaults?.[this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] ?? false);
if (auto_run && response.proposed_query && response.proposed_query.trim().length > 0) {
setTimeout(() => {
const current_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (current_guid === selected_guid) {
this.send_suggested_query(response.proposed_query);
}
}, 1000);
}
}
/**
* Populates prompt textarea with a suggested query and initiates request execution.
* @param {string} query - The proposed query string.
*/
send_suggested_query(query) {
if (this.prompt) {
this.prompt.value = query;
this.resize_prompt_textarea();
this.validate_prompt();
this.send();
}
}
/**
* Renders the conversation tab based on conversation type.
* @param {boolean} [scroll_to_last=true] - Whether to auto-scroll to the latest item.
* @returns {Promise<void>}
*/
async render_conversation_tab(scroll_to_last = true) {
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_SCRATCHPAD) {
await this.render_scratchpad_conversation(scroll_to_last);
} else {
await this.render_chat_conversation(scroll_to_last);
}
}
/**
* Renders chat history items for standard conversation.
* @param {boolean} [scroll_to_last=true] - Whether to auto-scroll.
* @returns {Promise<void>}
*/
async render_chat_conversation(scroll_to_last = true) {
if (!this.div_response) return;
const conversation = this.get_selected_conversation();
const app_defaults = this.#storage.get_app_defaults();
const show_suggested = (conversation && conversation[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
: (app_defaults?.[this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
const show_related = (conversation && conversation[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
: (app_defaults?.[this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);
const options = {
show_suggested: show_suggested,
show_related: show_related
};
this.div_response.innerHTML = this.#conversation.render(conversation, options);
await this._post_render_conversation(scroll_to_last);
}
/**
* Renders conversation items for scratchpad mode.
* @param {boolean} [scroll_to_last=true] - Whether to auto-scroll.
* @returns {Promise<void>}
*/
async render_scratchpad_conversation(scroll_to_last = true) {
if (!this.div_response) return;
const conversation = this.get_selected_conversation();
const app_defaults = this.#storage.get_app_defaults();
const show_suggested = (conversation && conversation[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
: (app_defaults?.[this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
const show_related = (conversation && conversation[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
: (app_defaults?.[this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);
const options = {
show_suggested: show_suggested,
show_related: show_related
};
this.div_response.innerHTML = this.#scratchpad_conversation.render(conversation, options);
await this._post_render_conversation(scroll_to_last);
}
/**
* Performs post-rendering setup like syntax highlighting, listeners attachment, and scrolling.
* @param {boolean} scroll_to_last - Whether to scroll to bottom.
* @private
*/
async _post_render_conversation(scroll_to_last) {
if (typeof hljs !== 'undefined') {
this.div_response.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
}
if (typeof mermaid !== 'undefined') {
const mermaidNodes = Array.from(this.div_response.querySelectorAll('.mermaid:not([data-processed="true"]), .div-diagram-mermaid:not([data-processed="true"])'))
.filter(node => !node.querySelector('svg') && (node.getAttribute('data-mermaid') || node.textContent || '').trim().length > 0);
for (let i = 0; i < mermaidNodes.length; i++) {
const node = mermaidNodes[i];
node.setAttribute('data-processed', 'true');
const rawCode = node.getAttribute('data-mermaid') || node.textContent || '';
const code = rawCode.trim();
if (!code) continue;
const id = 'mermaid_' + Date.now() + '_' + i + '_' + Math.random().toString(36).substring(2, 7);
try {
const result = await mermaid.render(id, code);
if (result && result.svg) {
node.innerHTML = result.svg;
if (typeof result.bindFunctions === 'function') {
result.bindFunctions(node);
}
}
} catch (e) {
console.warn('Mermaid render error for diagram:', e);
const errEl = document.getElementById(id) || document.getElementById('d' + id);
if (errEl) errEl.remove();
node.innerHTML = `<pre class="code-block language-mermaid"><code>${escapeHtml(code)}</code></pre>`;
}
}
}
if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) {
try {
await MathJax.typesetPromise([this.div_response]);
} catch (e) {
console.error('MathJax render error:', e);
}
}
this._attach_conversation_listeners();
if (scroll_to_last) {
this.scroll_to_last_item();
}
}
/**
* Attaches click event listeners to interactive elements inside the conversation response.
* @private
*/
_attach_conversation_listeners() {
this.div_response.querySelectorAll('.copyable-code').forEach((btn) => {
btn.onclick = () => {
const code = btn.getAttribute('data-code');
void copyToClipboard(code, btn);
};
});
this.div_response.querySelectorAll('.copyable-table').forEach((btn) => {
btn.onclick = () => {
const table = btn.closest('table') || btn.nextElementSibling;
const tableData = btn.getAttribute('data-table');
void copyToClipboard(table || tableData, btn);
};
});
this.div_response.querySelectorAll('.btn-copy-response, .btn-copy-scratchpad').forEach((btn) => {
btn.onclick = () => {
const index = btn.getAttribute('data-index');
const responseDiv = this.div_response.querySelector(`.div-response-content[data-index="${index}"]`);
if (responseDiv) {
void copyToClipboard(responseDiv, btn);
}
};
});
this.div_response.querySelectorAll('.btn-redo-response, .btn-redo-scratchpad').forEach((btn) => {
btn.onclick = async () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.#storage.get_conversation(selected_guid);
const history = conversation?.[this.#storage.KEY_CONVERSATION_HISTORY];
if (history && history[index]) {
const item = history[index];
const query = item.query || '';
const summary = item.summary || '';
let message = `Do you want to Re-send this query?
Query: "${query}"`;
if (summary) {
message += `
Summary: "${summary}"`;
}
if (await customConfirm('Re-send Query', message)) {
history.splice(index, 1);
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
this.#storage.save_conversation(selected_guid, conversation);
this.#storage.update_app_index(selected_guid, false);
this.prompt.value = query;
this.resize_prompt_textarea();
this.validate_prompt();
this.send();
}
}
}
};
});
this.div_response.querySelectorAll('.btn-undo-scratchpad').forEach((btn) => {
btn.onclick = async () => {
if (await customConfirm('Undo', 'Are you sure you want to delete the latest item and revert to the previous one?')) {
const index = parseInt(btn.getAttribute('data-index'), 10);
const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.#storage.get_conversation(selected_guid);
const history = conversation?.[this.#storage.KEY_CONVERSATION_HISTORY];
if (history && history[index]) {
history.splice(index, 1);
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
this.#storage.save_conversation(selected_guid, conversation);
this.#storage.update_app_index(selected_guid, false);
this.on_conversation_updated(false);
}
}
}
};
});
this.div_response.querySelectorAll('.btn-delete-response').forEach((btn) => {
btn.onclick = async () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.#storage.get_conversation(selected_guid);
const history = conversation?.[this.#storage.KEY_CONVERSATION_HISTORY];
if (history && history[index]) {
const item = history[index];
const summary = item.summary || item.query || 'this response';
if (await customConfirm('Delete Response', `Are you sure you want to delete: "${summary}"?`)) {
history.splice(index, 1);
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
this.#storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated(false);
}
}
}
};
});
this.div_response.querySelectorAll('.btn-delete-scratchpad').forEach((btn) => {
btn.onclick = async () => {
if (await customConfirm('Delete Scratchpad', 'Are you sure you want to delete this entire scratchpad conversation?')) {
const config = this.#storage.get_app_config();
const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid) {
this.#storage.index_delete(selected_guid);
if (this.#scratchpad_index) {
this.#scratchpad_index.on_conversation_index_updated();
}
const remaining = this.#scratchpad_index ? this.#scratchpad_index.get_scratchpad_conversations() : [];
if (remaining.length > 0) {
this.#storage.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, remaining[0][this.#storage.KEY_INDEX_GUID]);
this.on_conversation_updated(false);
} else {
this.close_conversation();
this.on_conversation_updated(false);
}
}
}
};
});
this.div_response.querySelectorAll('.p-thinking-header').forEach((header) => {
header.onclick = () => {
const container = header.closest('.div-thinking-content');
if (!container) return;
const isExpanded = container.classList.contains('thinking-expanded');
const arrow = header.querySelector('.span-thinking-arrow');
const textDiv = container.querySelector('.div-thinking-text');
if (isExpanded) {
container.classList.remove('thinking-expanded');
container.classList.add('thinking-collapsed');
if (arrow) arrow.textContent = '▶';
if (textDiv) textDiv.style.display = 'none';
} else {
container.classList.remove('thinking-collapsed');
container.classList.add('thinking-expanded');
if (arrow) arrow.textContent = '▼';
if (textDiv) textDiv.style.display = 'block';
}
};
});
this.div_response.querySelectorAll('.proposed-query-btn, .related-query-btn, .button-clarification, .span-clickable-query, .span-query-chip').forEach((btn) => {
btn.onclick = () => {
const query = btn.getAttribute('data-query') || btn.textContent.trim();
if (query) {
this.send_suggested_query(query);
}
};
});
this.div_response.querySelectorAll('.bookmark-response-btn').forEach((btn) => {
btn.onclick = () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.#storage.get_conversation(selected_guid);
const history = conversation[this.#storage.KEY_CONVERSATION_HISTORY];
if (history && history[index]) {
history[index].bookmark = !history[index].bookmark;
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
this.#storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated(false);
}
}
};
});
this.div_response.querySelectorAll('.chain-toggle-btn').forEach((btn) => {
btn.onclick = () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.#storage.get_conversation(selected_guid);
const history = conversation[this.#storage.KEY_CONVERSATION_HISTORY];
if (history && history[index]) {
history[index].chain = (history[index].chain === false) ? true : false;
conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
this.#storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated(false);
}
}
};
});
}
/**
* Updates full conversation UI whenever active conversation data is modified.
* @param {boolean} [scroll_to_last=true] - Whether to scroll to bottom.
*/
on_conversation_updated(scroll_to_last = true) {
const config = this.#storage.get_app_config();
const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (!selected_guid) {
this.state_i_open = false;
this._reset_chat_view();
this.apply_panels_layout();
this.validate_conversation_index_button();
return;
}
if (selected_guid === 'scratchpad') {
this.state_i_open = true;
if (window.innerWidth <= this.BREAKPOINT_TABLET) {
this.state_v_open = false;
}
this.apply_panels_layout();
this._setup_scratchpad_conversation_ui();
this._reset_chat_view();
this.#conversations_list?.apply_selected_index_class?.();
this.validate_conversation_index_button();
return;
}
setElementDisplay(this.div_chat_empty, 'none');
setElementDisplay(this.div_chat_empty_header, 'none');
(this.div_chat_ui_elements || []).forEach(el => setElementDisplay(el, ''));
setElementDisplay(this.div_chat_title_bar, '');
setElementDisplay(this.div_chat_prompt_inset, 'block');
const conversation = this.#storage.get_conversation(selected_guid);
const app_defaults = this.#storage.get_app_defaults();
const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE] || this.#storage.CONVERSATION_TYPE_CHAT;
if (type === this.#storage.CONVERSATION_TYPE_CHAT) {
const count = this.get_conversation_item_count();
if (count === 0 && this.state_i_open) {
this.state_i_open = false;
this.apply_panels_layout();
}
this._setup_chat_conversation_ui();
if (this.btn_conversation_new) this.btn_conversation_new.textContent = 'New Chat';
} else if (type === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) {
this._setup_scratchpad_conversation_ui();
if (this.btn_conversation_new) this.btn_conversation_new.textContent = 'New';
} else if (type === this.#storage.CONVERSATION_TYPE_NOTEBOOK) {
this._setup_notebook_conversation_ui();
if (this.btn_conversation_new) this.btn_conversation_new.textContent = 'New Notebook';
}
this._update_tab_visibility(this.state_active_tab, type, config[this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES]);
setElementDisplay(this.div_options_chips, 'block');
const verbosity = conversation?.[this.#storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
const family = conversation?.[this.#storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
const model_version = conversation?.[this.#storage.KEY_CONVERSATION_MODEL_VERSION] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
const short_model_name = formatModelShortName(family, model_version, Api.MODELS);
if (this.span_option_model) {
this.span_option_model.innerHTML = `<span style="opacity: 0.5">Model:</span> ${short_model_name}`;
}
if (this.span_option_verbosity) {
this.span_option_verbosity.innerHTML = `<span style="opacity: 0.5">Verbosity:</span> ${verbosity}`;
}
const show_suggested = (conversation && conversation[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
: (app_defaults?.[this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
const show_related = (conversation && conversation[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
: (app_defaults?.[this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);
const enforce_topics = (conversation && conversation[this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS]
: (app_defaults?.[this.#storage.KEY_CONFIG_ENFORCE_TOPICS] ?? false);
const auto_run = (conversation && conversation[this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] !== undefined)
? conversation[this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES]
: (app_defaults?.[this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] ?? false);
if (this.span_option_suggested) {
this.span_option_suggested.innerHTML = `<span style="opacity: 0.5">Suggested:</span> ${show_suggested ? 'ON' : 'OFF'}`;
}
if (this.span_option_related) {
this.span_option_related.innerHTML = `<span style="opacity: 0.5">Related:</span> ${show_related ? 'ON' : 'OFF'}`;
}
const famObj = Api.MODELS?.[family];
const modelObj = famObj?.models?.find(m => m.model === model_version);
const can_ground = !!modelObj?.grounding;
if (this.span_option_search) {
if (can_ground) {
const google_search = conversation?.[this.#storage.KEY_CONVERSATION_GOOGLE_SEARCH] || false;
this.span_option_search.innerHTML = `<span style="opacity: 0.5">Search:</span> ${google_search ? 'ON' : 'OFF'}`;
this.span_option_search.style.display = '';
} else {
this.span_option_search.style.display = 'none';
}
}
if (this.span_option_enforce_topics) {
this.span_option_enforce_topics.innerHTML = `<span style="opacity: 0.5">Enforce Topics:</span> ${enforce_topics ? 'ON' : 'OFF'}`;
}
if (this.span_option_auto_run) {
this.span_option_auto_run.innerHTML = `<span style="opacity: 0.5">Auto-Run:</span> ${auto_run ? 'ON' : 'OFF'}`;
}
this.render_conversation_header();
if (this.div_subtitle && this.span_subtitle_toggle) {
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') {
void 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();
}
this.validate_conversation_index_button();
}
//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 the options overlay modal.
*/
hide_options_overlay() {
if (this.div_options_overlay) {
this.div_options_overlay.style.display = 'none';
}
}
/**
* Handles clicks on the options overlay background to dismiss modal dialogs.
* @param {Event} e - The click event.
*/
handle_options_overlay_click(e) {
if (e.target === this.div_options_overlay) {
this.hide_options_overlay();
}
}
/**
* 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.#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}
* @private
*/
_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;
}
/**
* Populates the UI form fields with either default app options or conversation-specific settings.
* @param {('app'|'conversation')} type - The scope of settings to apply.
* @private
*/
_apply_options(type) {
const is_app = type === 'app';
const defaults = is_app ? this.#storage.get_app_defaults() : null;
const conversation = !is_app ? this.get_selected_conversation() : null;
const app_defaults = !is_app ? this.#storage.get_app_defaults() : null;
const verbosity_key = is_app ? this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY : this.#storage.KEY_CONVERSATION_VERBOSITY;
const show_suggested_key = is_app ? this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES : this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES;
const show_related_key = is_app ? this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES : this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES;
const enforce_topics_key = is_app ? this.#storage.KEY_CONFIG_ENFORCE_TOPICS : this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS;
const auto_send_key = is_app ? this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES : this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES;
const verbosity_val = is_app
? (defaults ? defaults[verbosity_key] : 'standard')
: (conversation ? (conversation[verbosity_key] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard') : (app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard'));
const family_val = is_app
? (defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini')
: (conversation?.[this.#storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini');
const version_val = is_app
? (defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite')
: (conversation?.[this.#storage.KEY_CONVERSATION_MODEL_VERSION] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite');
const thinking_val = is_app
? (defaults?.[this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL] !== undefined ? defaults[this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL] : 'MINIMAL')
: (conversation?.[this.#storage.KEY_CONVERSATION_THINKING_LEVEL] !== undefined ? conversation[this.#storage.KEY_CONVERSATION_THINKING_LEVEL] : (app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL] ?? 'MINIMAL'));
const matchedPreset = findMatchingPreset(Api.PRESETS, family_val, version_val, thinking_val);
const preset_val = is_app
? (defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL] !== undefined ? defaults[this.#storage.KEY_CONFIG_DEFAULT_MODEL] : matchedPreset)
: (conversation?.[this.#storage.KEY_CONVERSATION_MODEL] !== undefined ? conversation[this.#storage.KEY_CONVERSATION_MODEL] : (conversation ? matchedPreset : (app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL] || matchedPreset)));
const show_suggested_val = is_app
? (defaults ? defaults[show_suggested_key] : false)
: (conversation && conversation[show_suggested_key] !== undefined ? conversation[show_suggested_key] : (app_defaults ? app_defaults[this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] : false));
const show_related_val = is_app
? (defaults ? defaults[show_related_key] : false)
: (conversation && conversation[show_related_key] !== undefined ? conversation[show_related_key] : (app_defaults ? app_defaults[this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES] : false));
const enforce_topics_val = is_app
? (defaults ? defaults[enforce_topics_key] : false)
: (conversation && conversation[enforce_topics_key] !== undefined ? conversation[enforce_topics_key] : (app_defaults ? app_defaults[this.#storage.KEY_CONFIG_ENFORCE_TOPICS] : false));
const auto_send_val = is_app
? (defaults ? defaults[auto_send_key] : false)
: (conversation && conversation[auto_send_key] !== undefined ? conversation[auto_send_key] : (app_defaults ? app_defaults[this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] : false));
const verbosity_select = is_app ? this.select_default_verbosity : this.select_conversation_verbosity;
const preset_select = is_app ? this.select_default_model : this.select_conversation_model;
const family_select = is_app ? this.select_default_model_family : this.select_conversation_model_family;
const version_select = is_app ? this.select_default_model_version : this.select_conversation_model_version;
const level_select = is_app ? this.select_default_thinking_level : this.select_conversation_model_level;
const suggested_checkbox = is_app ? this.checkbox_default_show_suggestions : this.checkbox_conversation_show_suggestions;
const related_checkbox = is_app ? this.checkbox_default_show_related : this.checkbox_conversation_show_related;
const enforce_checkbox = is_app ? this.checkbox_default_enforce_topics : this.checkbox_conversation_enforce_topics;
const auto_send_checkbox = is_app ? this.checkbox_default_auto_send_prompts : this.checkbox_conversation_auto_send_prompts;
const play_chime_val = is_app ? (defaults ? defaults[this.#storage.KEY_CONFIG_PLAY_CHIME] : false) : false;
if (verbosity_select) verbosity_select.value = verbosity_val || 'standard';
populatePresetDropdown(preset_select, Api.PRESETS, preset_val);
populateFamilyDropdown(family_select, Api.MODELS, family_val);
const modelsList = Api.MODELS?.[family_val]?.models || [];
populateModelVersionDropdown(version_select, modelsList, version_val);
const modelObj = modelsList.find(m => m.model === version_val) || modelsList[0];
const thinkingModes = modelObj?.thinking_modes || [];
populateThinkingLevelDropdown(level_select, thinkingModes, thinking_val);
const descEl = is_app ? this.div_default_model_description : this.div_conversation_model_description;
const costEl = is_app ? this.div_default_model_costs : this.div_conversation_model_costs;
updateModelDetailsDisplay(descEl, costEl, family_val, version_val, thinking_val, Api.MODELS);
if (suggested_checkbox) suggested_checkbox.checked = show_suggested_val !== undefined ? show_suggested_val : false;
if (related_checkbox) related_checkbox.checked = show_related_val !== undefined ? show_related_val : false;
if (enforce_checkbox) enforce_checkbox.checked = !!enforce_topics_val;
if (auto_send_checkbox) auto_send_checkbox.checked = !!auto_send_val;
if (is_app && this.checkbox_default_play_chime) this.checkbox_default_play_chime.checked = !!play_chime_val;
if (!is_app) {
const google_search_val = conversation ? conversation[this.#storage.KEY_CONVERSATION_GOOGLE_SEARCH] : false;
if (this.checkbox_conversation_google_search) {
this.checkbox_conversation_google_search.checked = !!google_search_val;
}
const can_ground = !!modelObj?.grounding;
if (this.div_conversation_google_search_container) {
this.div_conversation_google_search_container.style.display = can_ground ? 'block' : 'none';
}
}
}
/**
* Handles change events on the model presets dropdown with confirmation prompt.
* @param {('app'|'conversation')} type - The scope of settings.
* @param {Event} e - The change event.
* @private
*/
async _on_preset_change(type, e) {
const is_app = type === 'app';
const newPresetKey = e.target.value;
if (!newPresetKey) {
if (is_app) {
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL, '');
} else {
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, this.#storage.KEY_CONVERSATION_MODEL, '');
}
}
this._apply_options(type);
this.on_conversation_updated();
return;
}
const preset = Api.PRESETS?.[newPresetKey];
if (!preset) return;
const pFamily = preset.provider || 'gemini';
const pModel = preset.model || 'gemini-3.5-flash-lite';
const pThinking = preset.thinking !== undefined ? preset.thinking : null;
const fullModelName = formatModelFullName(pFamily, pModel, null, Api.MODELS);
const thinkingLabel = formatThinkingLevelLabel(pThinking);
const confirmMsg = `Do you want to load preset "${preset.name || newPresetKey}"?\n\nModel: ${fullModelName}\nThinking Level: ${thinkingLabel}`;
const confirmed = await customConfirm('Load Preset', confirmMsg, 'Load', 'Cancel');
if (confirmed) {
if (is_app) {
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL, newPresetKey);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY, pFamily);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION, pModel);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL, pThinking);
} else {
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, this.#storage.KEY_CONVERSATION_MODEL, newPresetKey);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_MODEL_FAMILY, pFamily);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_MODEL_VERSION, pModel);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_THINKING_LEVEL, pThinking);
}
}
this._apply_options(type);
this.on_conversation_updated();
} else {
this._apply_options(type);
}
}
/**
* Handles change events on the model family dropdown.
* @param {('app'|'conversation')} type - The scope of settings.
* @param {Event} e - The change event.
* @private
*/
_on_family_change(type, e) {
const is_app = type === 'app';
const newFamily = e.target.value;
const modelsList = Api.MODELS?.[newFamily]?.models || [];
const firstModel = modelsList[0]?.model || '';
const firstThinking = modelsList[0]?.thinking_modes?.[0] ?? null;
const matchedPreset = findMatchingPreset(Api.PRESETS, newFamily, firstModel, firstThinking);
if (is_app) {
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY, newFamily);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION, firstModel);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL, firstThinking);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL, matchedPreset);
} else {
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, this.#storage.KEY_CONVERSATION_MODEL_FAMILY, newFamily);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_MODEL_VERSION, firstModel);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_THINKING_LEVEL, firstThinking);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_MODEL, matchedPreset);
}
}
this._apply_options(type);
this.on_conversation_updated();
}
/**
* Handles change events on the model version dropdown.
* @param {('app'|'conversation')} type - The scope of settings.
* @param {Event} e - The change event.
* @private
*/
_on_version_change(type, e) {
const is_app = type === 'app';
const newVersion = e.target.value;
let family;
let currentThinking;
if (is_app) {
const defaults = this.#storage.get_app_defaults();
family = defaults[this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
currentThinking = defaults[this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL];
} else {
const conversation = this.get_selected_conversation();
const app_defaults = this.#storage.get_app_defaults();
family = conversation?.[this.#storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
currentThinking = conversation?.[this.#storage.KEY_CONVERSATION_THINKING_LEVEL] !== undefined
? conversation[this.#storage.KEY_CONVERSATION_THINKING_LEVEL]
: app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL];
}
const modelsList = Api.MODELS?.[family]?.models || [];
const modelObj = modelsList.find(m => m.model === newVersion);
const thinkingModes = modelObj?.thinking_modes || [];
const normCurrentThinking = (currentThinking === null || currentThinking === undefined || currentThinking === '' || currentThinking === 'none') ? '' : String(currentThinking).toUpperCase();
const isSupported = thinkingModes.some(mode => {
const normMode = (mode === null || mode === undefined || mode === '' || mode === 'none') ? '' : String(mode).toUpperCase();
return normMode === normCurrentThinking;
});
let nextThinking = currentThinking;
if (!isSupported) {
nextThinking = thinkingModes[0] ?? null;
}
const matchedPreset = findMatchingPreset(Api.PRESETS, family, newVersion, nextThinking);
if (is_app) {
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION, newVersion);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL, nextThinking);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL, matchedPreset);
} else {
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, this.#storage.KEY_CONVERSATION_MODEL_VERSION, newVersion);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_THINKING_LEVEL, nextThinking);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_MODEL, matchedPreset);
}
}
this._apply_options(type);
this.on_conversation_updated();
}
/**
* Handles change events on the thinking level dropdown.
* @param {('app'|'conversation')} type - The scope of settings.
* @param {Event} e - The change event.
* @private
*/
_on_thinking_level_change(type, e) {
const is_app = type === 'app';
const val = e.target.value;
const newThinking = (val === '' || val === 'none') ? null : val;
let family;
let modelVersion;
if (is_app) {
const defaults = this.#storage.get_app_defaults();
family = defaults[this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
modelVersion = defaults[this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
} else {
const conversation = this.get_selected_conversation();
const app_defaults = this.#storage.get_app_defaults();
family = conversation?.[this.#storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
modelVersion = conversation?.[this.#storage.KEY_CONVERSATION_MODEL_VERSION] || app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
}
const matchedPreset = findMatchingPreset(Api.PRESETS, family, modelVersion, newThinking);
if (is_app) {
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL, newThinking);
this.#storage.update_app_defaults(this.#storage.KEY_CONFIG_DEFAULT_MODEL, matchedPreset);
} else {
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, this.#storage.KEY_CONVERSATION_THINKING_LEVEL, newThinking);
this.#storage.update_conversation_field(selected_guid, this.#storage.KEY_CONVERSATION_MODEL, matchedPreset);
}
}
this._apply_options(type);
this.on_conversation_updated();
}
/**
* Updates a specific configuration setting from user interaction with a form element.
* @param {('app'|'conversation')} type - The scope of the setting.
* @param {Event} e - The input or change event.
* @private
*/
_update_setting(type, e) {
const is_app = type === 'app';
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
const fieldMap = {
'id-select-default-verbosity': this.#storage.KEY_CONFIG_DEFAULT_VERBOSITY,
'id-select-conversation-verbosity': this.#storage.KEY_CONVERSATION_VERBOSITY,
'id-checkbox-default-show-suggestions': this.#storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES,
'id-checkbox-default-show-related': this.#storage.KEY_CONFIG_SHOW_RELATED_QUERIES,
'id-checkbox-default-enforce-topics': this.#storage.KEY_CONFIG_ENFORCE_TOPICS,
'id-checkbox-default-auto-send-prompts': this.#storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES,
'id-checkbox-default-play-chime': this.#storage.KEY_CONFIG_PLAY_CHIME,
'id-checkbox-conversation-show-suggestions': this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES,
'id-checkbox-conversation-show-related': this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES,
'id-checkbox-conversation-enforce-topics': this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS,
'id-checkbox-conversation-auto-send-prompts': this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES,
'id-checkbox-conversation-google-search': this.#storage.KEY_CONVERSATION_GOOGLE_SEARCH
};
const key = fieldMap[e.target.id];
if (!key) return;
if (is_app) {
this.#storage.update_app_defaults(key, value);
} else {
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);
}
}
this.on_conversation_updated();
}
/**
* Toggles a boolean configuration option for the currently active conversation.
* @param {string} key - The conversation option key to toggle.
* @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 app_defaults = this.#storage.get_app_defaults();
const current_value = (conversation[key] !== undefined)
? conversation[key]
: (app_defaults[key] || false);
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';
setElementDisplay(this.div_options_overlay, 'block');
setElementDisplay(this.form_app_options, is_app ? 'block' : 'none');
setElementDisplay(this.form_profile_options, is_app ? 'block' : 'none');
setElementDisplay(this.form_account_options, is_app ? 'block' : 'none');
setElementDisplay(this.form_admin_options, (is_app && Users.isAdmin) ? 'block' : 'none');
if (is_app && Users.instance) {
Users.instance.loadProfile();
if (Users.isAdmin) {
Users.instance.loadAdminUsers();
}
}
setElementDisplay(this.form_add_funds, 'none');
setElementDisplay(this.form_conversation_options, is_app ? 'none' : 'block');
if (is_app) {
this.apply_app_options();
this.apply_app_defaults();
const config = this.#storage.get_app_config();
if (this.checkbox_experimental_features) {
this.checkbox_experimental_features.checked = config[this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
}
if (this.checkbox_background_keep_alive) {
this.checkbox_background_keep_alive.checked = config[this.#storage.KEY_CONFIG_BACKGROUND_KEEP_ALIVE] || false;
}
} else {
this.apply_conversation_options();
}
}
/**
* Resets the chat view to its empty state when no conversation is selected.
* @private
*/
_reset_chat_view() {
setElementDisplay(this.div_chat_empty, 'grid');
setElementDisplay(this.div_chat_empty_header, 'block');
(this.div_chat_ui_elements || []).forEach(el => setElementDisplay(el, 'none'));
setElementDisplay(this.div_options_chips, 'none');
if (this.div_title) this.div_title.innerHTML = '';
if (this.div_subtitle) {
this.div_subtitle.innerHTML = '';
this.div_subtitle.style.maxHeight = null;
}
if (this.span_subtitle_toggle) {
this.span_subtitle_toggle.classList.remove('rotated');
}
setElementDisplay(this.div_chat_prompt_container, 'none');
setElementDisplay(this.div_chat_title_bar, 'none');
this._update_tab_visibility(null, null, false);
setElementDisplay(this.div_chat_prompt_inset, 'none');
setElementDisplay(this.btn_empty_new_scratchpad, 'block');
setElementDisplay(this.btn_show_scratchpads_new, 'block');
setElementDisplay(this.btn_empty_new_chat, 'block');
setElementDisplay(this.btn_show_conversations_new, 'block');
const config = this.#storage?.get_app_config();
const experimental_features_enabled = config?.[this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
setElementDisplay(this.btn_empty_new_notebook, experimental_features_enabled ? 'block' : 'none');
const notebookRow = getEl('id-div-empty-notebook-row');
if (notebookRow) {
notebookRow.style.display = experimental_features_enabled ? 'flex' : 'none';
}
}
/**
* Checks whether the active conversation is a scratchpad.
* @returns {boolean} True if scratchpad is active.
*/
is_scratchpad_active() {
const config = this.#storage.get_app_config();
const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid === 'scratchpad') {
return true;
}
const conversation = this.#storage.get_conversation(selected_guid);
return conversation && conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD;
}
/**
* Encapsulates all logic for showing/hiding tab buttons and scroll containers declaratively.
* @param {string|null} activeTabName - The name of the currently active tab.
* @param {string|null} conversationType - The type of the current conversation.
* @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) {
setElementDisplay(tabs[key].btn, 'none');
tabs[key].btn.classList.remove('active');
}
if (tabs[key].scroll) {
setElementDisplay(tabs[key].scroll, 'none');
}
}
setElementDisplay(this.div_chat_tab_bar, 'none');
setElementDisplay(this.div_notebook_tab_bar, 'none');
setElementDisplay(this.div_chat_prompt_container, 'none');
if (!activeTabName || !conversationType) {
return;
}
// Show relevant tab bars and buttons based on conversation type
if (conversationType === this.#storage.CONVERSATION_TYPE_CHAT || conversationType === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) {
setElementDisplay(this.div_chat_tab_bar, '');
setElementDisplay(tabs.conversation.btn, 'inline-block');
setElementDisplay(tabs.context.btn, 'inline-block');
if (experimentalFeaturesEnabled) {
setElementDisplay(tabs.memory.btn, 'inline-block');
}
} else if (conversationType === this.#storage.CONVERSATION_TYPE_NOTEBOOK) {
setElementDisplay(this.div_notebook_tab_bar, '');
setElementDisplay(tabs.document.btn, 'inline-block');
setElementDisplay(tabs.diction.btn, 'inline-block');
if (experimentalFeaturesEnabled) {
setElementDisplay(tabs['notebook-memory'].btn, 'inline-block');
}
}
// Set active tab and show its content
const currentTab = tabs[activeTabName];
if (currentTab) {
const is_visible_by_type = currentTab.type === ((conversationType === this.#storage.CONVERSATION_TYPE_CHAT || conversationType === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) ? '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) {
setElementDisplay(currentTab.scroll, 'grid');
}
} else {
if (conversationType === this.#storage.CONVERSATION_TYPE_CHAT || conversationType === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) {
this.state_active_tab = 'conversation';
if (tabs.conversation.btn) tabs.conversation.btn.classList.add('active');
if (tabs.conversation.scroll) setElementDisplay(tabs.conversation.scroll, 'grid');
} else if (conversationType === this.#storage.CONVERSATION_TYPE_NOTEBOOK) {
this.state_active_tab = 'document';
if (tabs.document.btn) tabs.document.btn.classList.add('active');
if (tabs.document.scroll) setElementDisplay(tabs.document.scroll, 'grid');
}
}
} else {
if (conversationType === this.#storage.CONVERSATION_TYPE_CHAT || conversationType === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) {
this.state_active_tab = 'conversation';
if (tabs.conversation.btn) tabs.conversation.btn.classList.add('active');
if (tabs.conversation.scroll) setElementDisplay(tabs.conversation.scroll, 'grid');
} else if (conversationType === this.#storage.CONVERSATION_TYPE_NOTEBOOK) {
this.state_active_tab = 'document';
if (tabs.document.btn) tabs.document.btn.classList.add('active');
if (tabs.document.scroll) setElementDisplay(tabs.document.scroll, 'grid');
}
}
const tabsWithPrompt = ['conversation', 'document'];
if (this.div_chat_prompt_container) {
setElementDisplay(this.div_chat_prompt_container, tabsWithPrompt.includes(this.state_active_tab) ? 'grid' : 'none');
}
}
/**
* Sets up UI panels and tab bars for a standard chat conversation.
* @private
*/
_setup_chat_conversation_ui() {
setElementDisplay(this.div_chat_tab_bar, '');
setElementDisplay(this.div_notebook_tab_bar, 'none');
setElementDisplay(this.div_index_list, '');
setElementDisplay(this.div_structure_center_index_options, 'grid');
setElementDisplay(this.div_structure_center_notebook_options, 'none');
this.#conversation_index.on_conversation_index_updated();
if (this.div_chat_memory_scroll) {
this.div_chat_memory_scroll.classList.add('chat-memory-style');
}
}
/**
* Sets up UI panels and tab bars for a scratchpad conversation.
* @private
*/
_setup_scratchpad_conversation_ui() {
setElementDisplay(this.div_chat_tab_bar, '');
setElementDisplay(this.div_notebook_tab_bar, 'none');
setElementDisplay(this.div_index_list, '');
setElementDisplay(this.div_structure_center_index_options, 'grid');
setElementDisplay(this.div_structure_center_notebook_options, 'none');
this.#scratchpad_index.on_conversation_index_updated();
if (this.div_chat_memory_scroll) {
this.div_chat_memory_scroll.classList.add('chat-memory-style');
}
}
/**
* Sets up UI panels and tab bars for a notebook conversation.
* @private
*/
_setup_notebook_conversation_ui() {
setElementDisplay(this.div_chat_tab_bar, 'none');
setElementDisplay(this.div_notebook_tab_bar, '');
setElementDisplay(this.div_index_list, 'none');
setElementDisplay(this.div_structure_center_index_options, 'none');
setElementDisplay(this.div_structure_center_notebook_options, 'grid');
this.#notebook_index.render(this.div_index_list);
if (this.div_chat_memory_scroll) {
this.div_chat_memory_scroll.classList.add('notebook-memory-style');
}
}
}
export default App;