-
🌐 async-utils.jsWeb, 3.69 kB⬇
-
🌐 auth-storage.jsWeb, 735 B⬇
-
🌐 chat-helpers.jsWeb, 8.85 kB⬇
-
🌐 chat-renderer.jsWeb, 70.00 kB⬇
-
🌐 code-overlay.jsWeb, 21.77 kB⬇
-
🌐 confirm-dialog.jsWeb, 3.17 kB⬇
-
🌐 conversation-format-utils.jsWeb, 21.51 kB⬇
-
🌐 diagram-overlay.jsWeb, 7.93 kB⬇
-
🌐 dialog.jsWeb, 2.50 kB⬇
-
🌐 dom-utils.jsWeb, 8.33 kB⬇
-
🌐 drag-reorder.jsWeb, 4.82 kB⬇
-
🌐 elements.jsWeb, 11.06 kB⬇
-
🌐 export-utils.jsWeb, 1.53 kB⬇
-
🌐 format-utils.jsWeb, 29.60 kB⬇
-
🌐 history-navigation.jsWeb, 1.95 kB⬇
-
🌐 hljs-yaml.jsWeb, 5.07 kB⬇
-
🌐 inline-format.jsWeb, 7.35 kB⬇
-
🌐 instant-answer.jsWeb, 19.82 kB⬇
-
🌐 interactive-tools.jsWeb, 38.77 kB⬇
-
🌐 mermaid-fixes.jsWeb, 4.60 kB⬇
-
🌐 mermaid-render.jsWeb, 5.10 kB⬇
-
🌐 mermaid.jsWeb, 195 B⬇
-
🌐 migrations.jsWeb, 12.11 kB⬇
-
🌐 multi-choice-dialog.jsWeb, 1.33 kB⬇
-
🌐 notification-stack.jsWeb, 2.50 kB⬇
-
🌐 options-manager.jsWeb, 45.56 kB⬇
-
🌐 overlay-utils.jsWeb, 4.14 kB⬇
-
🌐 pdf-export.jsWeb, 23.59 kB⬇
-
🌐 pinch-zoom.jsWeb, 3.21 kB⬇
-
🌐 plugin-dialog.jsWeb, 2.17 kB⬇
-
🌐 read-aloud.jsWeb, 33.84 kB⬇
-
🌐 response-pipeline.jsWeb, 55.19 kB⬇
-
🌐 scratchpad-editor.jsWeb, 8.64 kB⬇
-
🌐 scratchpad-export.jsWeb, 3.56 kB⬇
-
🌐 scrollbars.jsWeb, 3.12 kB⬇
-
🌐 selection-manager.jsWeb, 3.76 kB⬇
-
🌐 settings-utils.jsWeb, 31.08 kB⬇
-
🌐 share.jsWeb, 10.75 kB⬇
-
🌐 side-drawer.jsWeb, 9.37 kB⬇
-
🌐 speech-text.jsWeb, 6.60 kB⬇
-
🌐 swipe-navigation.jsWeb, 6.16 kB⬇
-
🌐 table-overlay.jsWeb, 8.10 kB⬇
-
🌐 theme-utils.jsWeb, 8.34 kB⬇
-
🌐 thinking-panel-base.jsWeb, 14.88 kB⬇
-
🌐 ui-sets.jsWeb, 1.00 kB⬇
-
🌐 version-overlay.jsWeb, 4.63 kB⬇
import { getEl } from './dom-utils.js';
import { scroller_of } from './scrollbars.js';
/** @typedef {import('../app.js').default} App */
/**
* Small chat UI helpers: scrolling, prompt validation, conversation counts, and sound alerts.
*/
class ChatHelpers {
#scrollTimeout = null;
/**
* @param {App} app - The main application instance.
*/
constructor(app) {
this.app = app;
}
/**
* Cancels any pending scheduled scroll operation.
*/
cancel_scroll() {
if (this.#scrollTimeout) {
clearTimeout(this.#scrollTimeout);
this.#scrollTimeout = null;
}
}
/**
* Scrolls the chat container to the most recent response in the history.
*/
scroll_to_last_item() {
this.cancel_scroll();
const config = this.app.storage.get_app_config();
if (config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID]) {
const history = this.get_selected_conversation()?.[this.app.storage.KEY_CONVERSATION_HISTORY];
if (history && history.length > 0) {
const lastIndex = history.length - 1;
const lastItem = history[lastIndex];
this.#scrollTimeout = setTimeout(() => {
this.#scrollTimeout = null;
if (lastItem && lastItem.pending) {
const container = scroller_of(this.app.div_chat_container_scroll);
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
} else {
const lastEl = getEl('chat-item-' + lastIndex);
if (lastEl) {
lastEl.scrollIntoView({ behavior: 'smooth' });
}
}
}, this.app.SCROLL_DELAY);
}
}
}
/**
* Returns the count of scratchpad conversations in the index.
* @returns {number} The count of scratchpad conversations.
*/
get_scratchpad_count() {
const index = this.app.storage.get_app_index();
let count = 0;
(index || []).forEach(item => {
const guid = item[this.app.storage.KEY_INDEX_GUID];
const conversation = this.app.storage.get_conversation(guid);
const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || item?.[this.app.storage.KEY_CONVERSATION_TYPE];
if (type === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
count++;
}
});
return count;
}
/**
* Validates the state of the conversation index button: it is hidden
* whenever there is no index to show (welcome screen, scratchpad root
* selection, empty conversation, page documents), and otherwise labelled
* for what the index lists, always enabled. The label is stored on the
* app and rendered by update_nav_button_states with the ❌/tooltip
* open-close affordances.
*/
validate_conversation_index_button() {
if (!this.app.btn_conversation_index) return;
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
let label = '';
if (selected_guid && selected_guid !== 'scratchpad' && this.app.is_scratchpad_active()) {
label = 'Scratchpads';
const count = this.get_scratchpad_count();
if (count === 0) {
label = '';
if (this.app.state_i_open) {
this.app.state_i_open = false;
this.app.apply_panels_layout();
}
}
} else if (selected_guid && this.app.is_document_active()) {
const doc_type = this.app.get_active_document_type();
if (doc_type !== this.app.storage.DOCUMENT_TYPE_PAGE) {
label = this.app.storage.get_document_index_label(doc_type);
}
} else if (selected_guid && (this.app.is_tools_active() || this.app.is_tool_conversation_active())) {
label = 'Tools';
} else if (selected_guid) {
if (this.get_conversation_item_count() > 0) {
label = 'History';
}
}
this.app.index_button_label = label;
this.app.update_nav_button_states();
}
/**
* Returns the number of responses in the currently selected conversation.
* @returns {number} The count of history items.
*/
get_conversation_item_count() {
const config = this.app.storage.get_app_config();
const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (guid) {
const conversation = this.app.storage.get_conversation(guid);
if (conversation && conversation[this.app.storage.KEY_CONVERSATION_HISTORY]) {
return conversation[this.app.storage.KEY_CONVERSATION_HISTORY].length;
}
}
return 0;
}
/**
* Checks whether the prompt textarea contains valid text.
* @returns {boolean} True if prompt is non-empty and conversation is idle.
*/
validate_prompt() {
if (!this.app.btn_send) return false;
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const isActive = this.app.api.is_request_active(selected_guid);
this.#validate_rank_button(selected_guid, isActive);
if (isActive) {
this.app.btn_send.disabled = true;
this.app.btn_send.textContent = '...';
return false;
}
this.app.btn_send.textContent = 'Send';
if (this.app.prompt && this.app.prompt.value && this.app.prompt.value.trim().length > 0) {
this.app.btn_send.disabled = false;
return true;
} else {
this.app.btn_send.disabled = true;
return false;
}
}
/**
* Syncs the Rank button state: enabled only on ranking conversations with
* a non-empty prompt, an idle conversation, and at least two choices.
* @param {string} selected_guid - The selected conversation guid.
* @param {boolean} isActive - Whether a request is running for it.
*/
#validate_rank_button(selected_guid, isActive) {
if (!this.app.btn_rank) return;
const conversation = this.app.storage.get_conversation(selected_guid);
const isRanking = !!conversation && conversation[this.app.storage.KEY_CONVERSATION_TYPE] === this.app.storage.CONVERSATION_TYPE_RANKING;
if (!isRanking) return;
const choices = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
const hasPrompt = !!(this.app.prompt && this.app.prompt.value.trim().length > 0);
this.app.btn_rank.textContent = 'Rank';
this.app.btn_rank.disabled = isActive || !hasPrompt || choices.length < 2;
}
/**
* Adjusts the height of the prompt textarea to match its content.
*/
resize_prompt_textarea() {
if (!this.app.prompt) return;
this.app.prompt.style.height = 'auto';
this.app.prompt.style.height = this.app.prompt.scrollHeight + 'px';
}
/**
* Checks if any scratchpad conversation currently has an active API request running.
* @returns {boolean}
*/
_is_any_scratchpad_loading() {
const activeRequests = this.app.api.get_active_requests();
return activeRequests.some(req => {
if (!req.guid) return false;
const conv = this.app.storage.get_conversation(req.guid);
return conv && conv[this.app.storage.KEY_CONVERSATION_TYPE] === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD;
});
}
/**
* Plays a subtle sound alert upon receiving a completion response.
*/
play_alert_sound() {
const app_defaults = this.app.storage.get_app_defaults();
const play_chime = app_defaults?.[this.app.storage.KEY_CONFIG_PLAY_CHIME] ?? false;
if (!play_chime) return;
try {
const AudioContextClass = window.AudioContext || window['webkitAudioContext'];
if (!AudioContextClass) return;
const ctx = new AudioContextClass();
const now = ctx.currentTime;
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.type = 'sine';
osc1.frequency.setValueAtTime(587.33, now); // D5
gain1.gain.setValueAtTime(0.15, now);
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
osc1.connect(gain1);
gain1.connect(ctx.destination);
osc1.start(now);
osc1.stop(now + 0.15);
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.type = 'sine';
osc2.frequency.setValueAtTime(880, now + 0.1); // A5
gain2.gain.setValueAtTime(0, now);
gain2.gain.setValueAtTime(0.15, now + 0.1);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.start(now + 0.1);
osc2.stop(now + 0.35);
} catch (e) {
console.warn('Audio feedback failed:', e);
}
}
/**
* Retrieves the conversation object for the currently selected GUID from storage.
* @returns {Object|null} The conversation object or null if none is selected.
*/
get_selected_conversation() {
return this.app.storage.get_selected_conversation();
}
/**
* Checks if a response or history item contains valid content to be rendered or sent.
* @param {Object} item - History turn or response payload.
* @returns {boolean}
*/
_has_valid_content(item) {
if (!item) return false;
if (typeof item.content === 'string' && item.content.length > 0) return true;
if (Array.isArray(item.content) && item.content.length > 0) return true;
if (typeof item.content === 'object' && item.content !== null && Object.keys(item.content).length > 0) return true;
if (Array.isArray(item.thinking) && item.thinking.length > 0) return true;
return typeof item.thinking === 'string' && item.thinking.trim().length > 0;
}
}
export default ChatHelpers;
chat-helpers.js
×
Type: Web, text/plain
8.85 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/plain
8.85 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File