-
🌐 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 { escapeHtml, highlightCodeBlocks, typesetMathjax } from './format-utils.js';
import { copyToClipboard } from './async-utils.js';
import { getEl, setElementDisplay, strip_tags } from './dom-utils.js';
import { customConfirm } from './confirm-dialog.js';
import { openDialog } from './dialog.js';
import { attach_code_expand_buttons } from './code-overlay.js';
import { attach_table_expand_buttons } from './table-overlay.js';
import { mermaid } from './mermaid.js';
import { render_mermaid_in_container, render_mermaid_svg, render_diagram_svg, attach_diagram_expand_button } from './mermaid-render.js';
import { formatModelShortName } from './settings-utils.js';
import { UI_SETS } from './ui-sets.js';
import RankingsView from '../rankings-view.js';
import { bind_read_buttons, check_autoplay } from './read-aloud.js';
import { refresh_scratchpad_drawer } from './scratchpad-editor.js';
import { content_of } from './scrollbars.js';
import Api from '../api.js';
/** @typedef {import('../app.js').default} App */
/**
* Renders conversation content: tabs, header, chat/scratchpad histories, and post-render hooks.
*/
class ChatRenderer {
rankings_view = new RankingsView();
/**
* @param {App} app - The main application instance.
*/
constructor(app) {
this.app = app;
this.visible_chips = new Set();
this.document_tab_guid = null;
this.notes_tab_guid = null;
this.tags_tab_guid = null;
}
/**
* 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.app.get_selected_conversation();
const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || this.app.storage.CONVERSATION_TYPE_CHAT;
const config = this.app.storage.get_app_config();
const experimental_features_enabled = config[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
this.app.state_active_tab = tabName;
this._update_tab_visibility(tabName, type, experimental_features_enabled);
const tabs = {
conversation: { render: () => this.render_conversation_tab(false) },
rankings: { render: this.render_rankings_tab.bind(this) },
tags: { render: this.render_tags_tab.bind(this) },
context: { render: this.render_context_tab.bind(this) },
attachments: { render: this.render_attachments_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) }
};
if (tabs[tabName] && typeof tabs[tabName].render === 'function') {
tabs[tabName].render();
}
}
/**
* Renders the content for the context tab.
*/
render_context_tab() {
if (!this.app.div_chat_context_scroll || !this.app.context) return;
content_of(this.app.div_chat_context_scroll).innerHTML = this.app.context.render();
this.app.context.attachEventListeners();
}
/**
* Renders the content for the attachments tab.
*/
render_attachments_tab() {
if (!this.app.div_chat_attachments_scroll || !this.app.attachments) return;
content_of(this.app.div_chat_attachments_scroll).innerHTML = this.app.attachments.render();
this.app.attachments.attachEventListeners();
}
/**
* Renders the content for the memory tab.
*/
render_memory_tab() {
const conversation = this.app.get_selected_conversation();
const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || this.app.storage.CONVERSATION_TYPE_CHAT;
if (type === this.app.storage.CONVERSATION_TYPE_CHAT) {
if (this.app.div_chat_memory_scroll) {
content_of(this.app.div_chat_memory_scroll).innerHTML = this.app.memory.render();
}
} else if (this.app.div_document_memory_scroll) {
content_of(this.app.div_document_memory_scroll).innerHTML = this.app.memory.render();
}
}
/**
* Renders the content for the document tab and binds change listener.
* Skips rebuilding while the editor holds focus or matches the stored
* state, so typing and the caret survive the storage event echo. A forced
* render (sync download) replaces the content regardless of focus.
*/
render_document_tab(force = false) {
if (!this.app.div_chat_document_scroll || !this.app.document) return;
const conversation = this.app.get_selected_conversation();
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
this.app.document.set_active_guid(selected_guid, this.app);
const items = Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_DOCUMENT]) ? conversation[this.app.storage.KEY_CONVERSATION_DOCUMENT] : [];
const is_same_document = this.document_tab_guid === selected_guid;
if (is_same_document && !force && (this.app.document.contains_focus() || this.app.document.matches_items(items))) return;
this.document_tab_guid = selected_guid;
content_of(this.app.div_chat_document_scroll).innerHTML = this.app.document.render(items);
this.app.document.attachEventListeners((newItems, content_changed = true) => {
if (selected_guid) {
this.app.storage.update_conversation_field(selected_guid, this.app.storage.KEY_CONVERSATION_DOCUMENT, newItems);
if (content_changed) {
this.app.response_pipeline.schedule_document_title_update(selected_guid);
}
this.app.sync?.request();
}
});
}
/**
* Re-renders the active document, sticky notes, or rankings choices
* after a sync download so pulled content replaces the visible content immediately.
* @param {string} guid - The conversation that was updated by the sync.
*/
refresh_after_sync_down(guid) {
if (this.document_tab_guid === guid) {
void this.render_document_tab(true);
}
if (this.notes_tab_guid === guid) {
this.render_notes_tab(true);
}
if (this.tags_tab_guid === guid) {
this.render_tags_tab(true);
}
if (this.choices_tab_guid === guid) {
this.render_choices_tab(true);
}
if (this.app.state_active_tab === 'rankings') {
this.render_rankings_tab();
}
}
/**
* Renders the content for the diction tab and binds change listener.
*/
render_diction_tab() {
if (!this.app.div_chat_diction_scroll || !this.app.diction) return;
const conversation = this.app.get_selected_conversation();
const currentDiction = conversation ? conversation[this.app.storage.KEY_CONVERSATION_DICTION] || '' : '';
content_of(this.app.div_chat_diction_scroll).innerHTML = this.app.diction.render(currentDiction);
this.app.diction.attachEventListeners((newDiction) => {
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid) {
this.app.storage.update_conversation_field(selected_guid, this.app.storage.KEY_CONVERSATION_DICTION, newDiction);
this.app.sync?.request();
}
});
}
/**
* Regenerates the conversation title after the most recent message was deleted.
* Uses the summaries of the remaining responses via the conversation_title utility,
* or a "Cleared" timestamp title when no responses remain.
* @param {Object} conversation - The conversation object.
* @param {string} guid - The conversation GUID.
*/
async update_conversation_title_after_delete(conversation, guid) {
if (conversation?.[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE]) return;
const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY] || [];
let title = '';
if (history.length === 0) {
title = 'Cleared: ' + format_cleared_date(new Date());
} else {
const summaries = history
.map(item => (item?.summary || item?.query || '').trim())
.filter(text => text !== '');
if (summaries.length === 0) return;
const config = this.app.storage.get_app_config();
const utilityModel = config[this.app.storage.KEY_CONFIG_UTILITY_MODEL] || 'gemini-3.5-fl';
try {
const response = await this.app.api.evaluate_utility(summaries.join('\n'), 'conversation_title', utilityModel);
title = (response && !response.error && response.explanation)
? String(response.explanation).trim().replace(/^["']+|["']+$/g, '')
: '';
} catch (error) {
return;
}
if (title === '') return;
}
conversation[this.app.storage.KEY_CONVERSATION_TITLE] = title;
if (history.length > 0 && history[history.length - 1]) {
history[history.length - 1].title = title;
}
this.app.storage.save_conversation(guid, conversation);
this.on_conversation_updated(false);
const renamed_type = conversation[this.app.storage.KEY_CONVERSATION_TYPE];
if (renamed_type === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
this.app.scratchpad_index?.on_conversation_index_updated();
} else if (renamed_type === this.app.storage.CONVERSATION_TYPE_RANKING || renamed_type === this.app.storage.CONVERSATION_TYPE_COLORS || renamed_type === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES) {
this.app.tools_index?.on_tools_index_updated();
} else {
this.app.content_list?.on_app_index_updated();
}
}
/**
* Shows or hides the sync switch in the tab bar and the sync icon on the
* send button, based on the selected conversation's SYNCED setting. The
* Collaborate and Share buttons share the sync switch's visibility, with
* Collaborate additionally gated behind the Collaboration experimental
* feature.
*/
_update_sync_indicators() {
const storage = this.app.storage;
const selected_guid = storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
const supported = !!selected_guid && selected_guid !== 'scratchpad' && selected_guid !== 'tools';
const conversation = this.app.get_selected_conversation();
const synced = supported && !!conversation?.[storage.KEY_CONVERSATION_SYNCED];
if (this.app.label_sync_switch) {
this.app.label_sync_switch.style.display = supported ? '' : 'none';
}
if (this.app.checkbox_sync_switch) {
this.app.checkbox_sync_switch.checked = synced;
}
if (this.app.btn_send) {
this.app.btn_send.classList.toggle('sending-sync', synced);
}
const collaboration_enabled = this.app.options_manager?.is_collaboration_enabled() || false;
if (this.app.btn_share) {
this.app.btn_share.style.display = supported ? '' : 'none';
}
if (this.app.btn_collaborate) {
this.app.btn_collaborate.style.display = supported && collaboration_enabled ? '' : 'none';
}
this.app.share?.update_share_indicator();
}
/**
* Renders the chat interface header, displaying the title of the selected
* conversation and its thinking indicator state.
* A custom title (set through the title's edit button) wins over the
* newest revision's conversation title; otherwise the title comes from
* the newest revision (the overall conversation title).
*/
render_conversation_header() {
const conversation = this.app.get_selected_conversation();
const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY] || [];
let title = conversation?.[this.app.storage.KEY_CONVERSATION_TITLE] || 'New Conversation';
if (!conversation?.[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE]) {
for (let i = history.length - 1; i >= 0; i--) {
const item = history[i];
if (item && (item.title || item.conversationTitle)) {
title = item.conversationTitle || title;
break;
}
}
}
const sync_icon = conversation?.[this.app.storage.KEY_CONVERSATION_SYNCED] ? ' <span class="span-title-sync as-icon" title="Synced to server">☁︎</span>' : '';
if (this.app.div_title) {
let h4_title = this.app.div_title.querySelector('h4');
if (h4_title) {
h4_title.innerHTML = title + sync_icon;
} else {
this.app.div_title.insertAdjacentHTML('afterbegin', `<h4>${title}${sync_icon}</h4>`);
}
}
if (this.app.btn_title_edit) {
this.app.btn_title_edit.style.display = conversation ? 'inline-block' : 'none';
}
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
this.set_header_loading(!!selected_guid && this.app.api.is_request_active(selected_guid));
}
/**
* Opens the title dialog: a text entry pre-filled with the conversation's
* title, a magic wand that fills the entry with a generated title, a Save
* button (Enter in the entry works too) that marks the title custom and
* stops the automatic updates, a Clear button that resumes the automatic
* updates and regenerates the title once, and a Cancel button that leaves
* the title alone. Escape also cancels.
*/
edit_conversation_title() {
const conversation = this.app.get_selected_conversation();
if (!conversation) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'prompt-style-input';
input.maxLength = 100;
input.value = conversation[this.app.storage.KEY_CONVERSATION_TITLE] || '';
const wand = document.createElement('button');
wand.type = 'button';
wand.className = 'as-icon';
wand.title = 'Generate a title automatically';
wand.innerHTML = '🪄';
wand.addEventListener('click', async () => {
const text = this.#title_source_text(conversation);
if (!text) return;
wand.disabled = true;
try {
const title = await this.#generated_title(text);
if (title) input.value = title;
} finally {
wand.disabled = false;
}
});
const row = document.createElement('div');
row.className = 'div-title-dialog-row';
row.append(input, wand);
const apply = (save) => {
if (save) {
this.#save_custom_title(input.value.trim());
}
};
const close = openDialog({
title: 'Edit Title',
content: row,
buttons: [
{ caption: 'Save', onclick: (close) => { apply(true); close(); } },
{ caption: 'Clear', onclick: (close) => { this.#resume_automatic_title(conversation); close(); } },
{ caption: 'Cancel', onclick: (close) => close() }
],
click_outside: true
});
input.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
apply(true);
close();
});
input.focus();
}
/**
* Saves a custom title for the selected conversation: an empty value
* clears the custom flag so automatic updates resume, a non-empty value
* marks the title custom, which stops the automatic updates.
* @param {string} value - The new title.
*/
#save_custom_title(value) {
const config = this.app.storage.get_app_config();
const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (guid) {
const conv = this.app.storage.get_conversation(guid);
conv[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] = value !== '';
conv[this.app.storage.KEY_CONVERSATION_TITLE] = value;
this.app.storage.save_conversation(guid, conv);
this.app.storage.update_app_index(guid);
this.app.content_list?.on_app_index_updated?.();
this.app.tools_index?.on_tools_index_updated?.();
this.app.scratchpad_index?.on_conversation_index_updated?.();
}
this.render_conversation_header();
}
/**
* Clears the custom title flag so automatic title updates resume, then
* runs the automatic title generation once. Dispatches on the
* conversation type; chat-like conversations regenerate from history.
* @param {Object} conversation - The conversation.
*/
#resume_automatic_title(conversation) {
const storage = this.app.storage;
const config = storage.get_app_config();
const guid = config[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (guid) {
const conv = storage.get_conversation(guid);
conv[storage.KEY_CONVERSATION_CUSTOM_TITLE] = false;
storage.save_conversation(guid, conv);
const type = conv[storage.KEY_CONVERSATION_TYPE];
if (type === storage.CONVERSATION_TYPE_DOCUMENT) {
void this.app.response_pipeline.update_document_title(guid);
} else if (type === storage.CONVERSATION_TYPE_SCRATCHPAD) {
void this.app.response_pipeline.update_scratchpad_title(guid);
} else if (type === storage.CONVERSATION_TYPE_RANKING) {
void this.app.response_pipeline.update_ranking_title(guid);
} else if (type === storage.CONVERSATION_TYPE_STICKY_NOTES) {
void this.app.response_pipeline.update_sticky_title(guid);
} else {
void this.update_conversation_title_after_delete(conv, guid);
}
}
this.render_conversation_header();
}
/**
* Builds the text the conversation_title utility works on, based on the
* conversation type.
* @param {Object} conversation - The conversation.
* @returns {string} The source text, empty when there is nothing to read.
*/
#title_source_text(conversation) {
const storage = this.app.storage;
const type = conversation[storage.KEY_CONVERSATION_TYPE];
const strip = (value) => String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (type === storage.CONVERSATION_TYPE_DOCUMENT) {
const items = Array.isArray(conversation[storage.KEY_CONVERSATION_DOCUMENT]) ? conversation[storage.KEY_CONVERSATION_DOCUMENT] : [];
return items.map(item => strip(item?.value)).filter(Boolean).join('\n').slice(0, 4000);
}
if (type === storage.CONVERSATION_TYPE_SCRATCHPAD) {
const history = conversation[storage.KEY_CONVERSATION_HISTORY] || [];
const items = Array.isArray(history[history.length - 1]?.items) ? history[history.length - 1].items : [];
return items.map(item => strip(item?.value)).filter(Boolean).join('\n').slice(0, 4000);
}
if (type === storage.CONVERSATION_TYPE_RANKING || type === storage.CONVERSATION_TYPE_COLORS) {
const items = Array.isArray(conversation[storage.KEY_CONVERSATION_CHOICES]) ? conversation[storage.KEY_CONVERSATION_CHOICES] : [];
return items.map(item => strip(item?.value)).filter(Boolean).join('\n').slice(0, 4000);
}
if (type === storage.CONVERSATION_TYPE_STICKY_NOTES) {
const notes = Array.isArray(conversation[storage.KEY_CONVERSATION_STICKIES]) ? conversation[storage.KEY_CONVERSATION_STICKIES] : [];
return notes
.map(note => strip([note?.title, ...(note?.items || []).map(item => item?.text)].join(' ')))
.filter(Boolean)
.join('\n')
.slice(0, 4000);
}
const history = conversation[storage.KEY_CONVERSATION_HISTORY] || [];
return history
.map(item => strip(item?.summary || item?.query))
.filter(Boolean)
.join('\n')
.slice(0, 4000);
}
/**
* Runs the conversation_title utility on the given text.
* @param {string} text - The source text.
* @returns {Promise<string>} The generated title, empty on failure.
*/
async #generated_title(text) {
const config = this.app.storage.get_app_config();
const utility_model = config[this.app.storage.KEY_CONFIG_UTILITY_MODEL] || 'gemini-3.5-fl';
try {
const response = await this.app.api.evaluate_utility(text, 'conversation_title', utility_model);
if (!response || response.error || response.evaluation === false) return '';
return String(response.explanation || '').trim().replace(/^["']+|["']+$/g, '');
} catch (error) {
return '';
}
}
/**
* Shows or hides the thinking indicator dot in the conversation title bar.
* @param {boolean} is_loading - Whether the selected conversation has an active request.
*/
set_header_loading(is_loading) {
if (this.app.span_title_loading) {
this.app.span_title_loading.style.display = is_loading ? 'inline-flex' : 'none';
}
}
/**
* 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.app.get_selected_conversation();
const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || this.app.storage.CONVERSATION_TYPE_CHAT;
if (type === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
await this.render_scratchpad_conversation(scroll_to_last);
} else if (type === this.app.storage.CONVERSATION_TYPE_RANKING || type === this.app.storage.CONVERSATION_TYPE_COLORS) {
this.render_choices_tab();
} else if (type === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES) {
this.render_notes_tab();
} else {
await this.render_chat_conversation(scroll_to_last);
}
}
/**
* Renders the sticky notes board for a sticky notes conversation. Skips
* rebuilding while the editor holds focus or matches the stored state, so
* typing and the caret survive the storage event echo.
* @param {boolean} [force=false] - Whether to force a rebuild even if focused.
*/
render_notes_tab(force = false) {
if (!this.app.div_notes_scroll || !this.app.sticky) return;
const conversation = this.app.get_selected_conversation();
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
const notes = this.#sticky_notes_of(conversation);
const tags = this.#sticky_tags_of(conversation);
const is_same_notes = this.notes_tab_guid === selected_guid;
if (is_same_notes && !force && (this.app.sticky.contains_focus() || this.app.sticky.matches_content(notes, tags))) return;
this.notes_tab_guid = selected_guid;
content_of(this.app.div_notes_scroll).innerHTML = this.app.sticky.render(notes, tags);
this.app.sticky.attach(content_of(this.app.div_notes_scroll), this.#sticky_change_callback(selected_guid));
this.app.sticky.set_active_guid(selected_guid, this.app);
}
/**
* Renders the tag manager for a sticky notes conversation's Tags tab,
* with the same skip and save behavior as the notes board.
* @param {boolean} [force=false] - Whether to force a rebuild even if focused.
*/
render_tags_tab(force = false) {
if (!this.app.div_chat_tags_scroll || !this.app.sticky) return;
const conversation = this.app.get_selected_conversation();
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
const notes = this.#sticky_notes_of(conversation);
const tags = this.#sticky_tags_of(conversation);
const is_same_tags = this.tags_tab_guid === selected_guid;
if (is_same_tags && !force && (this.app.sticky.contains_focus() || this.app.sticky.matches_tags(tags))) return;
this.tags_tab_guid = selected_guid;
content_of(this.app.div_chat_tags_scroll).innerHTML = this.app.sticky.render_tags(tags, notes);
this.app.sticky.attach_tags(content_of(this.app.div_chat_tags_scroll), this.#sticky_change_callback(selected_guid));
this.app.sticky.set_active_guid(selected_guid, this.app);
}
/**
* Returns the conversation's sticky notes array.
* @param {Object|null} conversation - The conversation.
* @returns {Array} The notes.
*/
#sticky_notes_of(conversation) {
return Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_STICKIES]) ? conversation[this.app.storage.KEY_CONVERSATION_STICKIES] : [];
}
/**
* Returns the conversation's tag vocabulary array.
* @param {Object|null} conversation - The conversation.
* @returns {Array} The tags.
*/
#sticky_tags_of(conversation) {
return Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_TAGS]) ? conversation[this.app.storage.KEY_CONVERSATION_TAGS] : [];
}
/**
* Returns the change callback that persists the editor's notes and tags
* to the conversation and schedules the title update.
* @param {string} selected_guid - The conversation guid.
* @returns {Function} The change callback.
*/
#sticky_change_callback(selected_guid) {
return (newNotes, newTags) => {
if (selected_guid) {
const conversation = this.app.storage.get_conversation(selected_guid);
if (conversation) {
conversation[this.app.storage.KEY_CONVERSATION_STICKIES] = newNotes;
conversation[this.app.storage.KEY_CONVERSATION_TAGS] = newTags;
this.app.storage.save_conversation(selected_guid, conversation);
}
this.app.response_pipeline?.schedule_sticky_title_update(selected_guid);
this.app.sync?.request();
}
};
}
/**
* Renders the Rankings tab for a ranking conversation: the recorded rank
* blocks with their results, and query chips that re-rank the list.
*/
render_rankings_tab() {
if (!this.app.div_chat_rankings_scroll) return;
const conversation = this.app.get_selected_conversation();
const app_defaults = this.app.storage.get_app_defaults();
const blocks = Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_RANKINGS]) ? conversation[this.app.storage.KEY_CONVERSATION_RANKINGS] : [];
const settings = {
show_suggested: (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false),
show_related: (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false)
};
settings.existing_choices = (Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [])
.map(item => this.#choice_text(item?.value).toLowerCase());
const content = content_of(this.app.div_chat_rankings_scroll);
const auto_run = (conversation && conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] ?? false);
content.innerHTML = this.rankings_view.render(blocks, settings);
// related chips are possible rankings: fill the prompt and highlight Rank
content.querySelectorAll('.span-related-query').forEach(chip => {
chip.onclick = () => this.#apply_ranking_query(chip.textContent.trim(), auto_run);
});
// suggested chips refine the choices themselves: fill the prompt and highlight Send
content.querySelectorAll('.span-suggested-query').forEach(chip => {
chip.onclick = () => this.#apply_refine_query(chip.textContent.trim(), auto_run);
});
// plus buttons prepend the additional item to the current choices
content.querySelectorAll('.div-rankings-add').forEach(button => {
button.onclick = () => this.#add_additional_choice(button.dataset.choice);
});
content.querySelectorAll('.div-rankings-delete').forEach(button => {
button.onclick = () => this.#delete_ranking(blocks, button.dataset.guid);
});
content.querySelectorAll('.div-rankings-revisit').forEach(button => {
button.onclick = () => this.#revisit_ranking(blocks, button.dataset.guid);
});
// a freshly recorded ranking scrolls into view
if (this.rankings_scroll_pending) {
this.rankings_scroll_pending = false;
content.querySelector('.div-rankings-block:last-of-type')?.scrollIntoView({ block: 'start' });
}
}
/**
* Removes one recorded ranking after confirmation.
* @param {Array<Object>} blocks - The conversation's rank blocks.
* @param {string} block_guid - The guid of the block to delete.
*/
async #delete_ranking(blocks, block_guid) {
const block = blocks.find(b => b.guid === block_guid);
if (!block) return;
const preview = (block.prompt || '').trim().slice(0, 60) || 'this ranking';
if (!(await customConfirm('Delete Ranking', `Are you sure you want to delete the ranking for "${preview}"? This cannot be undone.`))) {
return;
}
const config = this.app.storage.get_app_config();
const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const conversation = this.app.storage.get_conversation(guid);
if (!conversation) return;
conversation[this.app.storage.KEY_CONVERSATION_RANKINGS] = blocks.filter(b => b.guid !== block_guid);
this.app.storage.save_conversation(guid, conversation);
this.app.sync?.request();
this.render_rankings_tab();
}
/**
* Loads the choices from a recorded ranking back into the Choices tab and
* restores its prompt, after warning that the current choices will be
* replaced.
* @param {Array<Object>} blocks - The conversation's rank blocks.
* @param {string} block_guid - The guid of the block to revisit.
*/
async #revisit_ranking(blocks, block_guid) {
const block = blocks.find(b => b.guid === block_guid);
if (!block) return;
if (!(await customConfirm('Revisit Ranking', 'The current choices will be replaced with the ones from this ranking. Continue?'))) {
return;
}
const config = this.app.storage.get_app_config();
const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const conversation = this.app.storage.get_conversation(guid);
if (!conversation) return;
conversation[this.app.storage.KEY_CONVERSATION_CHOICES] = (block.items || [])
.filter(item => item.guid)
.map(item => ({ guid: item.guid, value: this.#choice_html(item.text || '') }));
this.app.storage.save_conversation(guid, conversation);
this.app.sync?.request();
if (this.app.prompt) {
this.app.prompt.value = block.prompt || '';
this.app.prompt_drafts[guid] = block.prompt || '';
this.app.resize_prompt_textarea();
}
this.app.set_active_tab('conversation');
this.app.validate_prompt();
}
/**
* Converts stored plain-text choice content into the editor's inline html.
* @param {string} text - The plain text.
* @returns {string} The inline html.
*/
#choice_html(text) {
return escapeHtml(String(text || '')).replace(/\n/g, '<br>');
}
/**
* Converts a choice value into plain text.
* @param {string} value - The choice's inline html.
* @returns {string} The plain text.
*/
#choice_text(value) {
return strip_tags(String(value || '').replace(/<br\s*\/?>/gi, '\n')).trim();
}
/**
* Fills the prompt with a related query (a possible ranking) and
* highlights the Rank button; auto-run executes it immediately.
* @param {string} query - The related query.
* @param {boolean} auto_run - Whether to run the ranking right away.
*/
#apply_ranking_query(query, auto_run) {
if (this.app.prompt) {
this.app.prompt.value = query;
this.app.resize_prompt_textarea();
}
this.app.btn_rank?.classList.add('btn-action-highlight');
this.app.btn_send?.classList.remove('btn-action-highlight');
this.app.validate_prompt();
if (auto_run) {
this.app.response_pipeline.send_rank_action();
} else {
// the action happens on the Choices tab, where the prompt lives
this.app.set_active_tab('conversation');
}
}
/**
* Fills the prompt with a suggestion (a choice refinement) and highlights
* the Send button; auto-run executes it immediately.
* @param {string} query - The refinement prompt.
* @param {boolean} auto_run - Whether to run it right away.
*/
#apply_refine_query(query, auto_run) {
if (this.app.prompt) {
this.app.prompt.value = query;
this.app.resize_prompt_textarea();
}
this.app.btn_send?.classList.add('btn-action-highlight');
this.app.btn_rank?.classList.remove('btn-action-highlight');
this.app.validate_prompt();
if (auto_run) {
this.app.send();
} else {
// the refinement runs on the Choices tab, where the prompt lives
this.app.set_active_tab('conversation');
}
}
/**
* Prepends an additional item from a ranking result to the top of the
* current choice list.
* @param {string} choice - The plain-text choice to add.
*/
#add_additional_choice(choice) {
const config = this.app.storage.get_app_config();
const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const conversation = this.app.storage.get_conversation(guid);
if (!conversation || !choice) return;
const choices = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
conversation[this.app.storage.KEY_CONVERSATION_CHOICES] = [
{ guid: crypto.randomUUID(), value: this.#choice_html(choice) },
...choices
];
this.app.storage.save_conversation(guid, conversation);
this.app.validate_prompt();
this.app.response_pipeline?.schedule_ranking_title_update(guid);
}
/**
* Renders the Choices editor for a ranking conversation: one plain-text
* editable item per stored choice. Skips rebuilding while the editor holds
* focus or matches the stored state, so typing and the caret survive the
* storage event echo.
* @param {boolean} [force=false] - Whether to force a rebuild even if focused.
*/
render_choices_tab(force = false) {
if (!this.app.div_choices_scroll || !this.app.choices) return;
const conversation = this.app.get_selected_conversation();
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
const items = Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
const is_same_choices = this.choices_tab_guid === selected_guid;
if (is_same_choices && !force && (this.app.choices.contains_focus() || this.app.choices.matches_items(items))) return;
this.choices_tab_guid = selected_guid;
content_of(this.app.div_choices_scroll).innerHTML = this.app.choices.render(items);
this.app.choices.attach(content_of(this.app.div_choices_scroll), (newItems) => {
if (selected_guid) {
this.app.storage.update_conversation_field(selected_guid, this.app.storage.KEY_CONVERSATION_CHOICES, newItems);
this.app.validate_prompt();
this.app.response_pipeline?.schedule_ranking_title_update(selected_guid);
this.app.sync?.request();
}
});
this.app.choices.set_active_guid(selected_guid, this.app);
}
/**
* 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.app.div_response) return;
const conversation = this.app.get_selected_conversation();
const app_defaults = this.app.storage.get_app_defaults();
const show_suggested = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
const show_related = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);
const options = {
show_suggested: show_suggested,
show_related: show_related
};
this.app.div_response.innerHTML = this.app.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.app.div_response) return;
const conversation = this.app.get_selected_conversation();
const app_defaults = this.app.storage.get_app_defaults();
const show_suggested = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
const show_related = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);
const options = {
show_suggested: show_suggested,
show_related: show_related
};
this.app.div_response.innerHTML = this.app.scratchpad_conversation.render(conversation, options);
await this._post_render_conversation(scroll_to_last);
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
refresh_scratchpad_drawer(this.app, selected_guid);
}
/**
* Measures the scratchpad's sticky toolbar height for the drawer to
* start beneath it: the toolbar sticks flush to the top of the chat
* scroll area, which coincides with the drawer's positioning container.
* @returns {number|null} Top offset in px, or null when no toolbar is shown.
*/
get_scratchpad_drawer_top_offset() {
const header = this.app.div_response?.querySelector('.div-chat-scratchpad-buttons');
if (!header) return null;
return header.getBoundingClientRect().height;
}
/**
* Renders Mermaid diagrams in the specified container element.
* @param {HTMLElement} container
*/
async _render_mermaid_in_container(container) {
await render_mermaid_in_container(container);
}
/**
* Renders Mermaid code into a node and inserts the resulting SVG, retrying
* once with automatic syntax repairs. Throws the render error when the
* diagram still cannot be rendered.
* @param {HTMLElement} node
* @param {string} code
*/
async _render_mermaid_svg(node, code) {
await render_mermaid_svg(node, code);
}
/**
* Renders Mermaid code to an SVG element, retrying once with automatic
* syntax repairs applied. Mermaid's temporary render elements are cleaned
* up between attempts.
* @param {string} code - The mermaid source code.
* @returns {Promise<{svg: SVGElement|null, bind_functions: function(Element): void|null, error: string}>}
*/
async _render_diagram_svg(code) {
return render_diagram_svg(code);
}
/**
* Adds (or refreshes) the expand button on a diagram container, placed in
* the container's top right corner. With a rendered SVG it opens the
* diagram overlay; without one (code fallback) clicking retries the
* render and shows the render error in the overlay when the diagram still
* cannot be drawn.
* @param {HTMLElement} node - The diagram container.
* @param {string} code - The mermaid source used to retry rendering.
*/
_attach_diagram_expand_button(node, code = '') {
attach_diagram_expand_button(node, code);
}
/**
* Re-renders all Mermaid diagrams in the container so already rendered
* diagrams pick up the current theme colors, then re-runs MathJax so any
* equations inside the diagrams are typeset again.
* @param {HTMLElement} container
*/
async rerender_mermaid_theme(container) {
if (typeof mermaid === 'undefined' || !container) return;
const nodes = Array.from(container.querySelectorAll('[data-mermaid]'));
for (let i = 0; i < nodes.length; i++) {
const code = (nodes[i].getAttribute('data-mermaid') || '').trim();
if (!code) continue;
try {
await this._render_mermaid_svg(nodes[i], code);
} catch (e) {
console.warn('Mermaid theme re-render error for diagram:', e);
}
}
await typesetMathjax(container);
}
/**
* Performs post-rendering setup like syntax highlighting, listeners attachment, and scrolling.
* @param {boolean} scroll_to_last - Whether to scroll to bottom.
*/
async _post_render_conversation(scroll_to_last) {
highlightCodeBlocks(this.app.div_response);
await this._render_mermaid_in_container(this.app.div_response);
attach_code_expand_buttons(this.app.div_response);
attach_table_expand_buttons(this.app.div_response);
await typesetMathjax(this.app.div_response);
this._attach_conversation_listeners();
if (scroll_to_last) {
this.app.scroll_to_last_item();
}
}
/**
* Attaches click event listeners to interactive elements inside the conversation response.
*/
_attach_conversation_listeners() {
bind_read_buttons(
this.app.div_response,
() => {
const config = this.app.storage.get_app_config();
return {
tts_model: config[this.app.storage.KEY_CONFIG_TTS_MODEL] || '',
voice: config[this.app.storage.KEY_CONFIG_TTS_VOICE] || ''
};
},
(index) => {
const guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
const conversation = guid ? this.app.storage.get_conversation(guid) : null;
const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
const item = history?.[Number(index)];
if (!item) return '';
if (conversation[this.app.storage.KEY_CONVERSATION_TYPE] === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
return String(item.conversationTitle || conversation[this.app.storage.KEY_CONVERSATION_TITLE] || '').trim();
}
return String(item.title || '').trim();
},
() => this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID],
() => this.app.storage
);
check_autoplay(this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID]);
this.app.div_response.querySelectorAll('.copyable-code').forEach((btn) => {
btn.onclick = () => {
const code = btn.getAttribute('data-code');
void copyToClipboard(code, btn);
};
});
this.app.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.app.div_response.querySelectorAll('.btn-copy-response, .btn-copy-scratchpad').forEach((btn) => {
btn.onclick = () => {
const index = btn.getAttribute('data-index');
const responseDiv = this.app.div_response.querySelector(`.div-response-content[data-index="${index}"]`);
if (responseDiv) {
void copyToClipboard(responseDiv, btn);
}
};
});
this.app.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.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.app.storage.get_conversation(selected_guid);
const history = conversation?.[this.app.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?\n\nQuery: "${query}"`;
if (summary) {
message += `\n\nSummary: "${summary}"`;
}
if (await customConfirm('Re-send Query', message)) {
history.splice(index);
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(selected_guid, conversation);
this.app.storage.update_app_index(selected_guid, false);
this.app.prompt.value = query;
this.app.prompt_drafts[selected_guid] = query;
this.app.resize_prompt_textarea();
this.app.validate_prompt();
this.app.send();
}
}
}
};
});
this.app.div_response.querySelectorAll('.btn-undo-scratchpad').forEach((btn) => {
btn.onclick = async () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
const message = index === 0
? 'Are you sure you want to remove this scratchpad entry?'
: 'Are you sure you want to delete the latest item and revert to the previous one?';
if (await customConfirm('Undo', message)) {
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.app.storage.get_conversation(selected_guid);
const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
if (history && history[index]) {
history.splice(index);
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(selected_guid, conversation);
this.app.storage.update_app_index(selected_guid, false);
this.on_conversation_updated(false);
}
}
}
};
});
this.app.div_response.querySelectorAll('.btn-delete-response').forEach((btn) => {
btn.onclick = async () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.app.storage.get_conversation(selected_guid);
const history = conversation?.[this.app.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}"?`)) {
const wasLastMessage = (index === history.length - 1);
history.splice(index, 1);
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated(false);
if (wasLastMessage) {
await this.update_conversation_title_after_delete(conversation, selected_guid);
}
}
}
}
};
});
this.app.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.app.storage.get_app_config();
const selected_guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid) {
delete this.app.prompt_drafts[selected_guid];
this.app.content_list?.index_delete(selected_guid);
if (this.app.scratchpad_index) {
this.app.scratchpad_index.on_conversation_index_updated();
}
const remaining = this.app.scratchpad_index ? this.app.scratchpad_index.get_scratchpad_conversations() : [];
if (remaining.length > 0) {
this.app.storage.update_app_config(this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, remaining[0][this.app.storage.KEY_INDEX_GUID]);
this.on_conversation_updated(false);
} else {
this.app.close_conversation();
this.on_conversation_updated(false);
}
}
}
};
});
this.app.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 = '\u25b6';
if (textDiv) textDiv.style.display = 'none';
} else {
container.classList.remove('thinking-collapsed');
container.classList.add('thinking-expanded');
if (arrow) arrow.textContent = '\u25bc';
if (textDiv) textDiv.style.display = 'block';
}
};
});
this.app.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.app.send_suggested_query(query);
}
};
});
this.app.div_response.querySelectorAll('.bookmark-response-btn').forEach((btn) => {
btn.onclick = () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.app.storage.get_conversation(selected_guid);
const history = conversation[this.app.storage.KEY_CONVERSATION_HISTORY];
if (history && history[index]) {
history[index].bookmark = !history[index].bookmark;
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated(false);
}
}
};
});
this.app.div_response.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.onclick = (e) => {
e.preventDefault();
const id = decodeURIComponent((anchor.getAttribute('href') || '').slice(1));
const target = document.getElementById(id) || document.getElementsByName(id)[0];
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
};
});
this.app.div_response.querySelectorAll('.chain-toggle-btn').forEach((btn) => {
btn.onclick = () => {
const index = parseInt(btn.getAttribute('data-index'), 10);
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (selected_guid && !isNaN(index)) {
const conversation = this.app.storage.get_conversation(selected_guid);
const history = conversation[this.app.storage.KEY_CONVERSATION_HISTORY];
if (history && history[index]) {
history[index].chain = (history[index].chain === false);
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(selected_guid, conversation);
this.on_conversation_updated(false);
}
}
};
});
this._bind_response_toolbar_wrap();
}
/**
* Watches each response toolbar in the rendered conversation and toggles
* its wrapped class whenever the buttons do not fit one row at the
* current width: copy with redo/undo/delete stay on the first row and
* the audio controls move to a full-width second row. Re-checked on
* container resize, so narrowing the surface (e.g. a pinned drawer)
* re-splits the rows.
*/
_bind_response_toolbar_wrap() {
this.app.div_response?.querySelectorAll('.div-chat-response-buttons').forEach((bar) => {
let last_width = null;
const update = () => {
const width = bar.clientWidth;
if (width === last_width) return;
last_width = width;
bar.classList.remove('wrapped');
bar.classList.add('wrap-check');
const overflow = bar.scrollWidth > bar.clientWidth + 1;
bar.classList.remove('wrap-check');
bar.classList.toggle('wrapped', overflow);
};
update();
new ResizeObserver(update).observe(bar);
});
}
/**
* 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.app.storage.get_app_config();
const selected_guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
// selection changed: empty the drawer now so stale content never
// lingers while the new conversation renders
if (this.app.scratchpad_drawer && this.app.scratchpad_drawer.source !== selected_guid) {
this.app.scratchpad_drawer.clear();
}
this.app._sync_prompt_draft(selected_guid);
this._update_sync_indicators();
if (!selected_guid) {
this.app.state_i_open = false;
this._reset_chat_view();
this.app.apply_panels_layout();
this.app.validate_conversation_index_button();
this.app.content_list?.apply_selected_index_class?.();
this.app.scratchpad_index?.highlight_active_index_item?.();
return;
}
if (selected_guid === 'tools') {
this.app.state_i_open = true;
if (window.innerWidth <= this.app.BREAKPOINT_TABLET) {
this.app.state_v_open = false;
}
this.app.apply_panels_layout();
this._setup_tools_conversation_ui();
this._reset_chat_view();
this.app.content_list?.apply_selected_index_class?.();
this.app.validate_conversation_index_button();
return;
}
if (selected_guid === 'scratchpad') {
this.app.state_i_open = true;
if (window.innerWidth <= this.app.BREAKPOINT_TABLET) {
this.app.state_v_open = false;
}
this.app.apply_panels_layout();
this._setup_scratchpad_conversation_ui();
this._reset_chat_view();
this.app.content_list?.apply_selected_index_class?.();
this.app.scratchpad_index?.highlight_active_index_item?.();
this.app.validate_conversation_index_button();
return;
}
setElementDisplay(this.app.div_chat_empty, 'none');
setElementDisplay(this.app.div_chat_empty_header, 'none');
this.app.div_chat_container?.classList.remove('div-chat-empty-state');
(this.app.div_chat_ui_elements || []).forEach(el => setElementDisplay(el, ''));
setElementDisplay(this.app.div_chat_title_bar, '');
setElementDisplay(this.app.div_chat_prompt_inset, 'block');
const conversation = this.app.storage.get_conversation(selected_guid);
const app_defaults = this.app.storage.get_app_defaults();
const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || this.app.storage.CONVERSATION_TYPE_CHAT;
if (type === this.app.storage.CONVERSATION_TYPE_CHAT) {
const count = this.app.get_conversation_item_count();
if (count === 0 && this.app.state_i_open) {
this.app.state_i_open = false;
this.app.apply_panels_layout();
}
this._setup_chat_conversation_ui();
if (this.app.btn_conversation_new) this.app.btn_conversation_new.textContent = 'New Chat';
} else if (type === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
this._setup_scratchpad_conversation_ui();
if (this.app.btn_conversation_new) this.app.btn_conversation_new.textContent = 'New';
} else if (type === this.app.storage.CONVERSATION_TYPE_RANKING || type === this.app.storage.CONVERSATION_TYPE_COLORS) {
this._setup_tools_conversation_ui();
if (this.app.btn_conversation_new) this.app.btn_conversation_new.textContent = 'New';
} else if (type === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES) {
this._setup_tools_conversation_ui();
if (this.app.btn_conversation_new) this.app.btn_conversation_new.textContent = 'New';
} else if (type === this.app.storage.CONVERSATION_TYPE_DOCUMENT) {
this._setup_document_conversation_ui(conversation);
}
this.app.content_list?.apply_selected_index_class?.();
this.app.scratchpad_index?.highlight_active_index_item?.();
this.app.tools_index?.highlight_active_index_item?.();
this._update_tab_visibility(this.app.state_active_tab, type, config[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES]);
const is_active_request = this.app.api.is_request_active(selected_guid);
if (is_active_request) {
this.app.show_progress_ui(selected_guid);
} else {
this.app.hide_progress_ui();
if (this.app.prompt) {
this.app.validate_prompt();
this.app.resize_prompt_textarea();
}
}
const verbosity = conversation?.[this.app.storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
const family = conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
const model_version = conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_VERSION] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
const short_model_name = formatModelShortName(family, model_version, Api.MODELS);
if (this.app.span_option_model) {
this.app.span_option_model.innerHTML = `<span style="opacity: 0.5">Model:</span> ${short_model_name}`;
}
if (this.app.span_option_verbosity) {
this.app.span_option_verbosity.innerHTML = `<span style="opacity: 0.5">Verbosity:</span> ${verbosity}`;
}
const show_suggested = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
const show_related = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);
const enforce_topics = (conversation && conversation[this.app.storage.KEY_CONVERSATION_ENFORCE_TOPICS] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_ENFORCE_TOPICS]
: (app_defaults?.[this.app.storage.KEY_CONFIG_ENFORCE_TOPICS] ?? false);
const auto_run = (conversation && conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES]
: (app_defaults?.[this.app.storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] ?? false);
if (this.app.span_option_suggested) {
this.app.span_option_suggested.innerHTML = `<span style="opacity: 0.5">Suggested:</span> ${show_suggested ? 'ON' : 'OFF'}`;
}
if (this.app.span_option_related) {
this.app.span_option_related.innerHTML = `<span style="opacity: 0.5">Related:</span> ${show_related ? 'ON' : 'OFF'}`;
}
if (this.app.span_option_search) {
const google_search = conversation?.[this.app.storage.KEY_CONVERSATION_SEARCH] || false;
this.app.span_option_search.innerHTML = `<span style="opacity: 0.5">Search:</span> ${google_search ? 'ON' : 'OFF'}`;
this.app.span_option_search.style.display = this.visible_chips.has('search') ? '' : 'none';
}
if (this.app.span_option_enforce_topics) {
this.app.span_option_enforce_topics.innerHTML = `<span style="opacity: 0.5">Enforce Topics:</span> ${enforce_topics ? 'ON' : 'OFF'}`;
}
if (this.app.span_option_auto_run) {
this.app.span_option_auto_run.innerHTML = `<span style="opacity: 0.5">Auto-Run:</span> ${auto_run ? 'ON' : 'OFF'}`;
}
this.render_conversation_header();
if (this.app.state_active_tab === 'conversation') {
void this.render_conversation_tab(scroll_to_last);
} else if (this.app.state_active_tab === 'context') {
this.render_context_tab();
} else if (this.app.state_active_tab === 'tags') {
this.render_tags_tab();
} else if (this.app.state_active_tab === 'attachments') {
this.render_attachments_tab();
} else if (this.app.state_active_tab === 'memory') {
this.render_memory_tab();
} else if (this.app.state_active_tab === 'document') {
this.render_document_tab();
} else if (this.app.state_active_tab === 'diction') {
this.render_diction_tab();
} else if (this.app.state_active_tab === 'rankings') {
this.render_rankings_tab();
}
this.app.validate_conversation_index_button();
}
/**
* Resets the chat view to its empty state when no conversation is selected.
*/
_reset_chat_view() {
setElementDisplay(this.app.div_chat_empty, 'grid');
setElementDisplay(this.app.div_chat_empty_header, 'block');
this.app.div_chat_container?.classList.add('div-chat-empty-state');
(this.app.div_chat_ui_elements || []).forEach(el => setElementDisplay(el, 'none'));
setElementDisplay(this.app.div_options_chips, 'none');
if (this.app.div_title) this.app.div_title.innerHTML = '';
this.set_header_loading(false);
setElementDisplay(this.app.div_chat_prompt_container, 'none');
setElementDisplay(this.app.div_chat_title_bar, 'none');
this._update_tab_visibility(null, null, false);
setElementDisplay(this.app.div_chat_prompt_inset, 'none');
setElementDisplay(this.app.btn_empty_new_scratchpad, 'block');
setElementDisplay(this.app.btn_show_scratchpads_new, 'block');
setElementDisplay(this.app.btn_empty_new_chat, 'block');
setElementDisplay(this.app.btn_show_content, 'block');
setElementDisplay(this.app.div_choices_container, 'none');
this.app.choices?.set_drawer_visible?.(false);
setElementDisplay(this.app.div_notes_container, 'none');
this.app.sticky?.set_drawer_visible?.(false);
const config = this.app.storage?.get_app_config();
const experimental_features_enabled = config?.[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
const documents_enabled = experimental_features_enabled && (config?.[this.app.storage.KEY_CONFIG_DOCUMENTS] || false);
setElementDisplay(this.app.select_new_document, 'block');
for (const option of this.app.select_new_document?.options || []) {
if (option.value && option.value !== 'PAGE') option.hidden = !documents_enabled;
}
const documentRow = getEl('id-div-empty-document-row');
if (documentRow) {
documentRow.style.display = 'flex';
}
}
/**
* Builds the inventories of every toggleable element in the prompt box
* area, with the current experimental feature flags applied.
* @param {boolean} experimentalFeaturesEnabled - Whether experimental features are enabled.
* @returns {{tabs: Object, rows: Object, chips: Object, flags: Object}} The element inventories and feature flags.
*/
_ui_inventory(experimentalFeaturesEnabled) {
const config = this.app.storage.get_app_config();
const enabled = (key) => experimentalFeaturesEnabled && (config?.[key] || false);
const flags = {
attachments: enabled(this.app.storage.KEY_CONFIG_ATTACHMENTS),
memory: enabled(this.app.storage.KEY_CONFIG_MEMORY),
diction: enabled(this.app.storage.KEY_CONFIG_DICTION)
};
const tabs = {
conversation: { btn: this.app.btn_tab_conversation, scroll: this.app.div_chat_container_scroll, prompt: true },
rankings: { btn: this.app.btn_tab_rankings, scroll: this.app.div_chat_rankings_scroll },
tags: { btn: this.app.btn_tab_tags, scroll: this.app.div_chat_tags_scroll },
context: { btn: this.app.btn_tab_context, scroll: this.app.div_chat_context_scroll },
attachments: { btn: this.app.btn_tab_attachments, scroll: this.app.div_chat_attachments_scroll, feature: 'attachments' },
memory: { btn: this.app.btn_tab_memory, scroll: this.app.div_chat_memory_scroll, alt_scroll: this.app.div_document_memory_scroll, feature: 'memory' },
document: { btn: this.app.btn_tab_document, scroll: this.app.div_chat_document_scroll, prompt: true },
diction: { btn: this.app.btn_tab_diction, scroll: this.app.div_chat_diction_scroll, feature: 'diction' }
};
const rows = {
nav: this.app.div_buttons_nav,
chat: this.app.div_buttons_chat,
document: this.app.div_buttons_document
};
const chips = {
model: this.app.span_option_model,
verbosity: this.app.span_option_verbosity,
suggested: this.app.span_option_suggested,
related: this.app.span_option_related,
search: this.app.span_option_search,
'enforce-topics': this.app.span_option_enforce_topics,
'auto-run': this.app.span_option_auto_run
};
return { tabs, rows, chips, flags };
}
/**
* Applies the UI visibility sets for the current conversation mode: the
* tab bar buttons, prompt button rows, and option chips, then activates
* the requested tab with its scroll container. Falls back to the first
* visible tab of the set when the requested tab is not available in this
* mode or is gated off by its experimental feature. The scratchpad
* drawer is available while a scratchpad conversation is open on its
* conversation tab.
* @param {string|null} activeTabName - The name of the requested active tab.
* @param {string|null} conversationType - The type of the current conversation.
* @param {boolean} experimentalFeaturesEnabled - Whether experimental features are enabled.
*/
_update_tab_visibility(activeTabName, conversationType, experimentalFeaturesEnabled) {
const { tabs, rows, chips, flags } = this._ui_inventory(experimentalFeaturesEnabled);
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');
}
if (tabs[key].alt_scroll) {
setElementDisplay(tabs[key].alt_scroll, 'none');
}
}
for (const key in rows) {
setElementDisplay(rows[key], 'none');
}
for (const key in chips) {
setElementDisplay(chips[key], 'none');
}
setElementDisplay(this.app.div_tab_bar, 'none');
setElementDisplay(this.app.div_chat_prompt_container, 'none');
this.app.select_scratchpad_export?.classList.add('export-hidden');
this.app.scratchpad_drawer?.set_visible(false);
if (!activeTabName || !conversationType) {
return;
}
const setName = conversationType === this.app.storage.CONVERSATION_TYPE_DOCUMENT
? 'document'
: conversationType === this.app.storage.CONVERSATION_TYPE_RANKING
? 'ranking'
: conversationType === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES
? 'stickies'
: 'chat';
const set = UI_SETS[setName];
if (tabs.conversation.btn) {
tabs.conversation.btn.textContent = conversationType === this.app.storage.CONVERSATION_TYPE_RANKING
? 'Choices'
: conversationType === this.app.storage.CONVERSATION_TYPE_COLORS
? 'Palette'
: conversationType === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES
? 'Notes'
: 'Conversation';
}
setElementDisplay(this.app.div_tab_bar, '');
for (const key of set.rows) {
setElementDisplay(rows[key], '');
}
for (const key of set.chips) {
setElementDisplay(chips[key], '');
}
this.visible_chips = new Set(set.chips);
const visibleTabs = set.tabs.filter((key) => !tabs[key].feature || flags[tabs[key].feature]);
for (const key of visibleTabs) {
setElementDisplay(tabs[key].btn, 'inline-block');
}
const active = visibleTabs.includes(activeTabName) ? activeTabName : visibleTabs[0];
this.app.state_active_tab = active;
// document mode swaps the new-chat button for the preview/edit toggle;
// scratchpad mode shows the export select next to it
const is_document = conversationType === this.app.storage.CONVERSATION_TYPE_DOCUMENT;
const is_scratchpad = conversationType === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD;
const is_ranking = conversationType === this.app.storage.CONVERSATION_TYPE_RANKING;
setElementDisplay(this.app.btn_document_preview, is_document ? 'inline-block' : 'none');
setElementDisplay(this.app.btn_conversation_new, is_document ? 'none' : 'inline-block');
setElementDisplay(this.app.btn_rank, is_ranking ? 'inline-block' : 'none');
this.app.div_chat_buttons?.classList.toggle('ranking-buttons', is_ranking);
this.app.select_scratchpad_export?.classList.toggle('export-hidden', !is_scratchpad);
this.app.div_chat_buttons?.classList.toggle('document-buttons', is_document);
this.app.div_chat_buttons?.classList.toggle('scratchpad-buttons', is_scratchpad);
const tab = tabs[active];
tab.btn.classList.add('active');
// the option chips ride with prompt-bearing tabs, and with the
// Rankings tab where the Suggested/Related pills control chip visibility
const show_chips = !!tab.prompt || (conversationType === this.app.storage.CONVERSATION_TYPE_RANKING && active === 'rankings');
setElementDisplay(this.app.div_options_chips, show_chips ? 'block' : 'none');
let scroll = tab.scroll;
if (active === 'memory' && conversationType === this.app.storage.CONVERSATION_TYPE_DOCUMENT) {
scroll = tab.alt_scroll;
}
if (scroll) {
setElementDisplay(scroll, 'grid');
}
// tool conversations host the Choices editor on their conversation
// tab instead of the chat history, with the thinking drawer beside it
const is_tool_type = conversationType === this.app.storage.CONVERSATION_TYPE_RANKING || conversationType === this.app.storage.CONVERSATION_TYPE_COLORS;
const choices_ui = is_tool_type && active === 'conversation';
setElementDisplay(this.app.div_choices_container, choices_ui ? 'flex' : 'none');
if (choices_ui) {
setElementDisplay(this.app.div_chat_container_scroll, 'none');
}
this.app.choices?.set_drawer_visible?.(choices_ui);
if (choices_ui) {
// the container just became measurable: re-run the pin feasibility
// check that update_availability skipped while it was hidden
this.app.choices?.set_drawer_visible?.(true);
}
// sticky notes host their board on the conversation tab the same way
const notes_ui = conversationType === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES && active === 'conversation';
setElementDisplay(this.app.div_notes_container, notes_ui ? 'flex' : 'none');
if (notes_ui) {
setElementDisplay(this.app.div_chat_container_scroll, 'none');
}
this.app.sticky?.set_drawer_visible?.(notes_ui);
if (notes_ui) {
this.app.sticky?.set_drawer_visible?.(true);
}
if (this.app.div_chat_prompt_container) {
setElementDisplay(this.app.div_chat_prompt_container, tab.prompt ? 'grid' : 'none');
}
// the document editor chrome (toolbars + scroll area) shows with its tab
const document_ui = conversationType === this.app.storage.CONVERSATION_TYPE_DOCUMENT && active === 'document';
setElementDisplay(this.app.div_document_container, document_ui ? 'flex' : 'none');
this.app.scratchpad_drawer?.set_visible(
conversationType === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD && active === 'conversation'
);
}
/**
* Sets up the center panel for the tools index: the shared conversation
* toolbar (Select/Bookmark/Delete) drives the tool entries, and the tool
* trigger buttons are rendered at the top of the index list.
*/
_setup_tools_conversation_ui() {
setElementDisplay(this.app.div_index_list, '');
setElementDisplay(this.app.div_structure_center_index_options, 'grid');
setElementDisplay(this.app.div_structure_center_document_options, 'none');
this.app.tools_index.on_tools_index_updated();
}
/**
* Sets up the center panel for a standard chat conversation.
*/
_setup_chat_conversation_ui() {
setElementDisplay(this.app.div_index_list, '');
setElementDisplay(this.app.div_structure_center_index_options, 'grid');
setElementDisplay(this.app.div_structure_center_document_options, 'none');
this.app.conversation_index.on_conversation_index_updated();
}
/**
* Sets up the center panel for a scratchpad conversation.
*/
_setup_scratchpad_conversation_ui() {
setElementDisplay(this.app.div_index_list, '');
setElementDisplay(this.app.div_structure_center_index_options, 'grid');
setElementDisplay(this.app.div_structure_center_document_options, 'none');
this.app.scratchpad_index.on_conversation_index_updated();
}
/**
* Sets up the center panel for a document conversation. The document type
* is displayed as a section header above the index list. Opening and
* closing the column itself is handled at selection time; Page documents
* keep it closed.
* @param {Object} conversation - The document conversation.
*/
_setup_document_conversation_ui(conversation) {
const doc_type = conversation?.[this.app.storage.KEY_CONVERSATION_DOCUMENT_TYPE];
setElementDisplay(this.app.div_index_list, '');
setElementDisplay(this.app.div_structure_center_index_options, 'none');
setElementDisplay(this.app.div_structure_center_document_options, 'grid');
this.app.document_index.render(this.app.div_index_list, this.app.storage.get_document_type_label(doc_type));
}
}
/**
* Formats a date as "April 1, 1988 at 14:36".
* @param {Date} date - The date to format.
* @returns {string} The formatted date string.
*/
function format_cleared_date(date) {
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()} at ${hours}:${minutes}`;
}
export default ChatRenderer;
chat-renderer.js
×
Type: Web, text/x-c++
70.00 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/x-c++
70.00 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File