-
🌐 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 { formatThinkingLineHtml } from './format-utils.js';
import { getEl, setElementDisplay, render_token_fade, strip_tags } from './dom-utils.js';
import { append_following_bottom } from './scrollbars.js';
import { customAlert } from './confirm-dialog.js';
import { handleInteractiveRequest } from './interactive-tools.js';
/** @typedef {import('../app.js').default} App */
/**
* Chat API response. Most fields are dynamic and may be absent depending on the provider and request.
* @typedef {Object} ChatResponse
* @property {string} [title] - Updated conversation title.
* @property {string} [conversationTitle] - Updated conversation title (alternative field name).
* @property {string} [summary] - Updated conversation summary.
* @property {string} [conversationSummary] - Updated conversation summary (alternative field name).
* @property {string} [proposed_query] - Follow-up query proposed by the model.
* @property {Array} [topics] - Conversation topics.
* @property {Array} [considerations] - Conversation considerations.
* @property {string|string[]} [thinking] - Thought lines or newline-separated thoughts.
* @property {boolean} [chain] - Whether the response is part of a chained query.
* @property {string} [query] - The original user prompt query (assigned when storing the response).
*/
/**
* Manages the request/response lifecycle: sending prompts, progress UI, background keep-alive,
* streaming thinking, and success/failure handling.
*/
class ResponsePipeline {
#wakeLock = null;
#audioCtx = null;
#document_title_timer = null;
#scratchpad_title_timer = null;
#ranking_title_timer = null;
#sticky_title_timer = null;
/**
* @param {App} app - The main application instance.
*/
constructor(app) {
this.app = app;
}
/**
* Updates the loading indicator for a conversation or all conversations across the UI.
* @param {string|null} [guid=null] - Conversation GUID.
* @param {boolean|null} [isLoading=null] - Explicit loading state.
*/
update_conversation_loading_indicators(guid = null, isLoading = null) {
if (guid) {
const active = (isLoading !== null) ? Boolean(isLoading) : this.app.api.is_request_active(guid);
this.app.content_list?.set_content_loading?.(guid, active);
this.app.scratchpad_index?.set_scratchpad_loading?.(guid, active);
this.app.tools_index?.set_tool_loading?.(guid, active);
} else {
const index = this.app.storage.get_app_index() || [];
index.forEach(item => {
const g = item[this.app.storage.KEY_INDEX_GUID];
if (g) {
const active = this.app.api.is_request_active(g);
this.app.content_list?.set_content_loading?.(g, active);
this.app.scratchpad_index?.set_scratchpad_loading?.(g, active);
this.app.tools_index?.set_tool_loading?.(g, active);
}
});
}
const anyScratchpadLoading = this.app._is_any_scratchpad_loading();
this.app.content_list?.set_scratchpad_root_loading?.(anyScratchpadLoading);
const anyToolLoading = this.app._is_any_tool_loading();
this.app.content_list?.set_tools_root_loading?.(anyToolLoading);
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
this.app.chat_renderer?.set_header_loading?.(!!selected_guid && this.app.api.is_request_active(selected_guid));
}
/**
* Whether the experimental Show Chunks feature is enabled, requiring the
* master experimental features switch to be on as well.
* @returns {boolean}
*/
show_chunks_enabled() {
const config = this.app.storage.get_app_config();
return !!(config[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] && config[this.app.storage.KEY_CONFIG_SHOW_CHUNKS]);
}
/**
* Computes the total byte count of thinking data for the pending turn of a conversation from storage.
* @param {string} guid - Conversation GUID.
* @returns {number} Sum of bytes in thinking content.
*/
get_pending_thinking_bytes(guid) {
if (!guid) return 0;
const conversation = this.app.storage.get_conversation(guid);
const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
if (!Array.isArray(history) || history.length === 0) return 0;
const pendingItem = history.find(item => item && item.pending) || history[history.length - 1];
if (!pendingItem || !pendingItem.pending || !pendingItem.thinking) return 0;
if (Array.isArray(pendingItem.thinking)) {
return pendingItem.thinking.reduce((total, line) => {
if (typeof line === 'string') {
return total + (new TextEncoder().encode(line).length + 1);
}
return total;
}, 0);
} else if (typeof pendingItem.thinking === 'string') {
return new TextEncoder().encode(pendingItem.thinking).length;
}
return 0;
}
/**
* Prepares the payload and initiates sending a prompt to the API.
*/
send() {
if (!this.app.validate_prompt()) {
return;
}
if (this.app.is_document_active()) {
this.send_document_prompt(this.app.prompt.value);
return;
}
if (this.app.is_ranking_active()) {
this.send_ranking_prompt(this.app.prompt.value);
return;
}
if (this.app.is_sticky_active()) {
this.send_sticky_notes_prompt(this.app.prompt.value);
return;
}
if (this.app.is_scratchpad_active()) {
this.send_scratchpad_prompt(this.app.prompt.value);
return;
}
if (this.app.btn_send) {
this.app.btn_send.disabled = true;
this.app.btn_send.textContent = '...';
}
const query = this.app.prompt.value;
const guid = this.#resolve_prompt_guid(query, this.app.storage.CONVERSATION_TYPE_CHAT, false);
const context = [];
if (guid) {
const conversation = this.app.storage.get_conversation(guid);
let conversationHistory = conversation[this.app.storage.KEY_CONVERSATION_HISTORY] || [];
const wasEmpty = (conversationHistory.filter(item => !item.pending).length === 0);
conversationHistory = conversationHistory.filter(item => !item.pending && this.app._has_valid_content(item));
conversationHistory.push({
query: query,
pending: true,
thinking: []
});
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = conversationHistory;
this.app.storage.save_conversation(guid, conversation);
if (wasEmpty && window.innerWidth > this.app.BREAKPOINT_MOBILE) {
if (!this.app.state_v_open) {
this.app.state_i_open = true;
this.app.apply_panels_layout();
}
}
this.app.on_conversation_updated();
const history = conversation[this.app.storage.KEY_CONVERSATION_HISTORY];
if (history && history.length > 1) {
const max_full_values = 3;
const max_summaries = 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.app.storage.get_selected_conversation();
const app_defaults = this.app.storage.get_app_defaults();
const verbosity = selected_conversation?.[this.app.storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
const { family, model_version, thinking_level, model_key } = this.resolve_conversation_model();
const meta_context = this.#build_meta_context(selected_conversation, model_version);
this.show_progress_ui(guid);
void this.start_background_keep_alive();
this.update_conversation_loading_indicators(guid, true);
const payload = this.app.api.format_data(
query,
context,
verbosity,
model_key,
meta_context,
false,
{ family, model: model_version, thinking: thinking_level }
);
payload.max_tool_use = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_MAX_TOOL_USE] || 20;
payload.allow_tool_requests = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_ALLOW_TOOL_REQUESTS] || false;
void this.app.api.post(
payload,
(response, targetGuid) => {
this.send_success(response, query, targetGuid || guid);
},
(response, targetGuid) => {
this.send_failure(response, targetGuid || guid);
},
'chat',
null,
(bytes, targetGuid) => this.update_progress(bytes, targetGuid || guid),
guid
);
}
/**
* Resolves the selected conversation's model selection against the app defaults.
* @returns {{family: string, model_version: string, thinking_level: string, model_key: string}} The resolved model selection.
*/
resolve_conversation_model() {
const selected_conversation = this.app.storage.get_selected_conversation();
const app_defaults = this.app.storage.get_app_defaults();
const family = selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
const model_version = selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_VERSION] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
const thinking_level = (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_THINKING_LEVEL] !== undefined)
? selected_conversation[this.app.storage.KEY_CONVERSATION_THINKING_LEVEL]
: (app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_THINKING_LEVEL] ?? 'MINIMAL');
const has_explicit_model = !!(selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_FAMILY] || selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_VERSION]);
let model_key = selected_conversation?.[this.app.storage.KEY_CONVERSATION_MODEL];
if (model_key === undefined || model_key === null || model_key === '') {
model_key = has_explicit_model ? (model_version || '') : (app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL] || '');
}
return { family, model_version, thinking_level, model_key };
}
/**
* Returns whether web search is enabled on the selected conversation.
* @returns {boolean}
*/
conversation_search_enabled() {
const selected_conversation = this.app.storage.get_selected_conversation();
return !!selected_conversation?.[this.app.storage.KEY_CONVERSATION_SEARCH];
}
/**
* Sends the prompt to the documents endpoint for the active document,
* streaming reasoning into the document thinking panel. Like chat and
* scratchpad requests, the prompt input is replaced by the progress
* display (with a working Stop button) while the request runs.
* @param {string} prompt - The user's prompt.
*/
send_document_prompt(prompt) {
const trimmed = (prompt || '').trim();
if (!trimmed || !this.app.document) return;
const { model_version, model_key, thinking_level } = this.resolve_conversation_model();
const meta_context = {};
if (this.conversation_search_enabled()) {
meta_context.search = true;
}
if (this.app.btn_send) {
this.app.btn_send.disabled = true;
this.app.btn_send.textContent = '...';
}
if (this.app.prompt) {
this.app.prompt.value = '';
this.app.resize_prompt_textarea();
}
const guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const asked = this.app.document.ask(trimmed, {
model_key: model_key,
thinking: thinking_level,
meta_context: meta_context,
app: this.app,
guid: guid,
on_done: () => {
this.hide_progress_ui();
this.#restore_document_prompt_ui();
},
on_error: () => {
this.hide_progress_ui();
this.#restore_document_prompt_ui(prompt);
}
});
if (asked === false) {
// a request for this document is already in flight; restore the UI
this.#restore_document_prompt_ui(prompt);
return;
}
this.show_progress_ui(guid);
}
/**
* Sends the prompt to the rankings endpoint for the active ranking
* conversation's choice list, streaming reasoning into the ranking
* thinking panel. Like document requests, the prompt input is replaced by
* the progress display (with a working Stop button) while the request runs.
* @param {string} prompt - The user's prompt.
*/
send_ranking_prompt(prompt) {
const trimmed = (prompt || '').trim();
if (!trimmed || !this.app.choices) return;
const { model_version, model_key, thinking_level } = this.resolve_conversation_model();
const meta_context = {};
if (this.conversation_search_enabled()) {
meta_context.search = true;
}
if (this.app.btn_send) {
this.app.btn_send.disabled = true;
this.app.btn_send.textContent = '...';
}
if (this.app.prompt) {
this.app.prompt.value = '';
this.app.resize_prompt_textarea();
}
const guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const asked = this.app.choices.ask(trimmed, {
model_key: model_key,
thinking: thinking_level,
meta_context: meta_context,
app: this.app,
guid: guid,
on_done: () => {
this.hide_progress_ui();
this.#restore_document_prompt_ui();
},
on_error: () => {
this.hide_progress_ui();
this.#restore_document_prompt_ui(prompt);
}
});
if (asked === false) {
// a request for this choice list is already in flight; restore the UI
this.#restore_document_prompt_ui(prompt);
return;
}
this.show_progress_ui(guid);
}
/**
* Sends the prompt to the stickynotes endpoint for the active sticky
* notes conversation's board, streaming reasoning into the notes thinking
* panel. Like ranking requests, the prompt input is replaced by the
* progress display (with a working Stop button) while the request runs.
* @param {string} prompt - The user's prompt.
*/
send_sticky_notes_prompt(prompt) {
const trimmed = (prompt || '').trim();
if (!trimmed || !this.app.sticky) return;
const { model_version, model_key, thinking_level } = this.resolve_conversation_model();
const meta_context = {};
if (this.conversation_search_enabled()) {
meta_context.search = true;
}
if (this.app.btn_send) {
this.app.btn_send.disabled = true;
this.app.btn_send.textContent = '...';
}
if (this.app.prompt) {
this.app.prompt.value = '';
this.app.resize_prompt_textarea();
}
const guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const asked = this.app.sticky.ask(trimmed, {
model_key: model_key,
thinking: thinking_level,
meta_context: meta_context,
app: this.app,
guid: guid,
on_done: () => {
this.hide_progress_ui();
this.#restore_document_prompt_ui();
},
on_error: () => {
this.hide_progress_ui();
this.#restore_document_prompt_ui(prompt);
}
});
if (asked === false) {
// a request for this board is already in flight; restore the UI
this.#restore_document_prompt_ui(prompt);
return;
}
this.show_progress_ui(guid);
}
/**
* Runs the Rank button: sends the prompt and the current choice items to
* the ranking endpoint and records the result as a new block on the
* conversation's RANKINGS history, then flips to the Rankings tab. The
* choices themselves are never modified.
*/
send_rank_action() {
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 || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_RANKING) return;
if (this.app.api.is_request_active(guid)) return;
const choices = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
if (choices.length < 2) return;
const prompt = (this.app.prompt?.value || '').trim();
if (!prompt) return;
const { model_version, model_key, thinking_level } = this.resolve_conversation_model();
const meta_context = {};
if (this.conversation_search_enabled()) {
meta_context.search = true;
}
const show_suggested = (conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
: (this.app.storage.get_app_defaults()?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
const show_related = (conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
: (this.app.storage.get_app_defaults()?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);
if (this.app.btn_rank) {
this.app.btn_rank.disabled = true;
this.app.btn_rank.textContent = '...';
}
if (this.app.btn_send) {
this.app.btn_send.disabled = true;
this.app.btn_send.textContent = '...';
}
if (this.app.prompt) {
this.app.prompt.value = '';
this.app.resize_prompt_textarea();
}
// copied at send time so edits during the run cannot alter the record
const items_at_send = choices.map(item => ({ guid: item.guid, value: item.value || '' }));
const asked = this.app.choices.rank(prompt, {
model_key: model_key,
thinking: thinking_level,
meta_context: meta_context,
show_suggested: show_suggested,
show_related: show_related,
app: this.app,
guid: guid,
on_result: (response) => this.#store_rank_result(guid, prompt, items_at_send, response),
on_done: () => {
this.hide_progress_ui();
this.#restore_document_prompt_ui();
},
on_error: () => {
this.hide_progress_ui();
this.#restore_document_prompt_ui(prompt);
}
});
if (asked === false) {
this.#restore_document_prompt_ui(prompt);
return;
}
this.show_progress_ui(guid);
}
/**
* Appends a rank result block to the conversation's RANKINGS history.
* The full text of every ranked choice is copied at this point, so later
* edits to the choices never alter the recorded ranking.
* @param {string} guid - The conversation guid.
* @param {string} prompt - The ranking prompt.
* @param {Array<{guid: string, value: string}>} items_at_send - The choices as sent.
* @param {Object} response - The structured ranking response.
*/
#store_rank_result(guid, prompt, items_at_send, response) {
const text_of = (item_guid) => {
const item = items_at_send.find(i => i.guid === item_guid);
return item ? this.#choice_to_text(item.value) : '';
};
const block = {
guid: crypto.randomUUID(),
timestamp: Date.now(),
prompt: prompt,
reply: String(response?.reply || ''),
items: (Array.isArray(response?.ranked) ? response.ranked : []).map(ranked => ({
guid: ranked.guid,
index: ranked.index,
score: ranked.score,
reason: ranked.reason || '',
text: text_of(ranked.guid)
})),
additional_items: Array.isArray(response?.additional_items) ? response.additional_items : [],
suggestions: Array.isArray(response?.suggestions) ? response.suggestions : [],
related: Array.isArray(response?.related) ? response.related : []
};
const conversation = this.app.storage.get_conversation(guid);
if (!conversation) return;
const history = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_RANKINGS]) ? conversation[this.app.storage.KEY_CONVERSATION_RANKINGS] : [];
conversation[this.app.storage.KEY_CONVERSATION_RANKINGS] = [...history, block];
this.app.storage.save_conversation(guid, conversation);
this.app.sync?.request();
if (this.app.chat_renderer) {
this.app.chat_renderer.rankings_scroll_pending = true;
}
this.app.set_active_tab('rankings');
}
/**
* Converts a choice value into plain text, preserving line breaks.
* @param {string} value - The choice's inline html.
* @returns {string} The plain text.
*/
#choice_to_text(value) {
return strip_tags(String(value || '').replace(/<br\s*\/?>/gi, '\n')).replace(/\n{3,}/g, '\n\n').trim();
}
/**
* Restores the document prompt UI after a document request finishes:
* re-enables Send, shows the prompt input again, and optionally restores
* the prompt text.
* @param {string} [prompt=''] - Prompt text to restore, or empty to leave cleared.
*/
#restore_document_prompt_ui(prompt = '') {
if (this.app.btn_send) {
this.app.btn_send.disabled = false;
this.app.btn_send.textContent = 'Send';
}
setElementDisplay(getEl('div-prompt-input'), 'block');
if (this.app.prompt) {
this.app.prompt.value = prompt;
this.app.resize_prompt_textarea();
this.app.validate_prompt();
}
}
/**
* Sends the prompt to the scratchpad endpoint: the current scratchpad
* state (items and table of contents) is copied into a new pending
* revision and the request operates on that copy, so history and undo
* keep working and the changes summary attaches to the revision on
* completion. Thinking streams through the shared chat thinking path.
* @param {string} query - The user's edit instruction.
*/
send_scratchpad_prompt(query) {
const trimmed = (query || '').trim();
if (!trimmed) return;
if (this.app.btn_send) {
this.app.btn_send.disabled = true;
this.app.btn_send.textContent = '...';
}
const guid = this.#resolve_prompt_guid(trimmed, this.app.storage.CONVERSATION_TYPE_SCRATCHPAD, true);
const conversation = this.app.storage.get_conversation(guid);
let history = (conversation[this.app.storage.KEY_CONVERSATION_HISTORY] || []).filter(item => !item.pending);
const previous = history[history.length - 1];
const revision = {
query: trimmed,
pending: true,
thinking: [],
items: previous && Array.isArray(previous.items) ? JSON.parse(JSON.stringify(previous.items)) : [],
toc: previous && Array.isArray(previous.toc) ? JSON.parse(JSON.stringify(previous.toc)) : []
};
history.push(revision);
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(guid, conversation);
this.app.on_conversation_updated();
const selected_conversation = this.app.storage.get_conversation(guid);
const app_defaults = this.app.storage.get_app_defaults();
const verbosity = selected_conversation?.[this.app.storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
const { family, model_version, thinking_level, model_key } = this.resolve_conversation_model();
const meta_context = this.#build_meta_context(selected_conversation, model_version);
this.show_progress_ui(guid);
void this.start_background_keep_alive();
this.update_conversation_loading_indicators(guid, true);
const payload = this.app.api.format_data(
trimmed,
[],
verbosity,
model_key,
meta_context,
false,
{ family, model: model_version, thinking: thinking_level }
);
payload.items = revision.items;
payload.toc = revision.toc;
payload.prompt = trimmed;
payload.max_tool_use = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_MAX_TOOL_USE] || 20;
payload.allow_tool_requests = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_ALLOW_TOOL_REQUESTS] || false;
void this.app.api.post(
payload,
(response, targetGuid) => {
this.send_success(response, trimmed, targetGuid || guid);
},
(response, targetGuid) => {
this.send_failure(response, targetGuid || guid);
},
'scratchpad',
null,
(bytes, targetGuid) => this.update_progress(bytes, targetGuid || guid),
guid
);
}
/**
* Resolves the conversation guid for a new prompt: creating a conversation
* of the default type when sending from a root view (optionally from the
* scratchpad sentinel), and recording the prompt draft either way.
* @param {string} query - The prompt text, stored as the draft.
* @param {string} defaultType - The conversation type for a new conversation.
* @param {boolean} allowSentinel - Whether the 'scratchpad' sentinel guid starts a new conversation.
* @returns {string} The conversation guid.
*/
#resolve_prompt_guid(query, defaultType, allowSentinel) {
const config = this.app.storage.get_app_config();
let guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (!guid || (allowSentinel && guid === 'scratchpad')) {
guid = this.app.storage.update_app_index('', true, defaultType);
if (this.app.prompt_drafts['']) {
this.app.prompt_drafts[guid] = this.app.prompt_drafts[''];
delete this.app.prompt_drafts[''];
} else {
this.app.prompt_drafts[guid] = query;
}
this.app.active_conversation_guid = guid;
this.app.storage.update_app_config(this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, guid);
} else {
this.app.prompt_drafts[guid] = query;
}
return guid;
}
/**
* Builds the request meta context from the conversation's options and the
* resolved model: topics, considerations, search grounding, topic
* enforcement, and the safeguard/creativity flags.
* @param {Object|null} selected_conversation - The conversation object.
* @param {string} model_version - The resolved model version id.
* @returns {Object} The meta context payload.
*/
#build_meta_context(selected_conversation, model_version) {
const app_defaults = this.app.storage.get_app_defaults();
const meta_context = {};
if (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_TOPICS]) {
meta_context.topics = selected_conversation[this.app.storage.KEY_CONVERSATION_TOPICS];
}
if (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_CONSIDERATIONS]) {
meta_context.considerations = selected_conversation[this.app.storage.KEY_CONVERSATION_CONSIDERATIONS];
}
if (this.conversation_search_enabled()) {
meta_context.search = true;
}
meta_context.enforce_topics = selected_conversation?.[this.app.storage.KEY_CONVERSATION_ENFORCE_TOPICS] || app_defaults?.[this.app.storage.KEY_CONFIG_ENFORCE_TOPICS] || false;
const disable_safeguards = (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_DISABLE_SAFEGUARDS] !== undefined)
? selected_conversation[this.app.storage.KEY_CONVERSATION_DISABLE_SAFEGUARDS]
: (app_defaults?.[this.app.storage.KEY_CONFIG_DISABLE_SAFEGUARDS] || false);
const encourage_creativity = (selected_conversation && selected_conversation[this.app.storage.KEY_CONVERSATION_ENCOURAGE_CREATIVITY] !== undefined)
? selected_conversation[this.app.storage.KEY_CONVERSATION_ENCOURAGE_CREATIVITY]
: (app_defaults?.[this.app.storage.KEY_CONFIG_ENCOURAGE_CREATIVITY] || false);
meta_context.disable_safeguards = disable_safeguards;
meta_context.encourage_creativity = !!(disable_safeguards && encourage_creativity);
return meta_context;
}
/**
* Schedules the document title update: 20 seconds after the user's last
* edit, the conversation_title utility is run on the document content.
* @param {string} guid - The document conversation GUID.
*/
schedule_document_title_update(guid) {
if (!guid) return;
if (this.#document_title_timer) {
clearTimeout(this.#document_title_timer);
}
this.#document_title_timer = setTimeout(() => {
this.#document_title_timer = null;
void this.update_document_title(guid);
}, 20000);
}
/**
* Applies a title to a document conversation, cancels any pending
* utility-based title update, and refreshes the UI.
* @param {string} guid - The document conversation GUID.
* @param {string} title - The new title.
*/
apply_document_title(guid, title) {
const trimmed = (title || '').trim();
if (!trimmed || !guid) return;
const conversation = this.app.storage.get_conversation(guid);
if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE]) return;
if (this.#document_title_timer) {
clearTimeout(this.#document_title_timer);
this.#document_title_timer = null;
}
if (this.#ranking_title_timer) {
clearTimeout(this.#ranking_title_timer);
this.#ranking_title_timer = null;
}
if (this.#sticky_title_timer) {
clearTimeout(this.#sticky_title_timer);
this.#sticky_title_timer = null;
}
if (conversation[this.app.storage.KEY_CONVERSATION_TITLE] === trimmed) return;
conversation[this.app.storage.KEY_CONVERSATION_TITLE] = trimmed;
this.app.storage.save_conversation(guid, conversation);
this.app.storage.update_app_index(guid);
this.app.content_list?.on_app_index_updated?.();
this.app.on_conversation_updated(false);
}
/**
* Runs the conversation_title utility on the document's text content and
* applies the returned title to the document conversation.
* @param {string} guid - The document conversation GUID.
*/
async update_document_title(guid) {
if (this.#document_title_timer) {
clearTimeout(this.#document_title_timer);
this.#document_title_timer = null;
}
const conversation = this.app.storage.get_conversation(guid);
if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_DOCUMENT) return;
const items = conversation[this.app.storage.KEY_CONVERSATION_DOCUMENT];
if (!Array.isArray(items)) return;
const text = items
.map(item => (item?.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n')
.slice(0, 4000);
if (!text) return;
try {
const response = await this.app.api.evaluate_utility(text, 'conversation_title');
const title = (response?.explanation || '').trim();
if (!title || response?.evaluation === false) return;
this.apply_document_title(guid, title);
} catch (error) {
console.warn('Document title update failed:', error);
}
}
/**
* Schedules the scratchpad title update a few seconds after a request's
* edits completed; a follow-up request restarts the timer so the
* conversation_title utility runs once on the final scratchpad state.
* @param {string} guid - The scratchpad conversation GUID.
*/
schedule_scratchpad_title_update(guid) {
if (!guid) return;
if (this.#scratchpad_title_timer) {
clearTimeout(this.#scratchpad_title_timer);
}
this.#scratchpad_title_timer = setTimeout(() => {
this.#scratchpad_title_timer = null;
void this.update_scratchpad_title(guid);
}, 5000);
}
/**
* Runs the conversation_title utility on the full scratchpad content and
* applies the returned title to the conversation and its latest revision
* (whose conversation title is the one displayed in the indexes).
* @param {string} guid - The scratchpad conversation GUID.
*/
async update_scratchpad_title(guid) {
const conversation = this.app.storage.get_conversation(guid);
if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) return;
const history = conversation[this.app.storage.KEY_CONVERSATION_HISTORY] || [];
const revision = history[history.length - 1];
const items = Array.isArray(revision?.items) ? revision.items : [];
const text = items
.map(item => (item?.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n')
.slice(0, 4000);
if (!text) return;
try {
const response = await this.app.api.evaluate_utility(text, 'conversation_title');
const title = (response?.explanation || '').trim();
if (!title || response?.evaluation === false) return;
const current = conversation[this.app.storage.KEY_CONVERSATION_TITLE];
if (current === title) return;
conversation[this.app.storage.KEY_CONVERSATION_TITLE] = title;
if (revision && typeof revision === 'object') {
revision.conversationTitle = title;
}
this.app.storage.save_conversation(guid, conversation);
this.app.storage.update_app_index(guid);
this.app.scratchpad_index?.on_conversation_index_updated?.();
this.app.content_list?.on_app_index_updated?.();
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (guid === selected_guid) {
this.app.on_conversation_updated(false);
}
} catch (error) {
console.warn('Scratchpad title update failed:', error);
}
}
/**
* Schedules the ranking conversation title update 3 seconds after the
* last choice edit, so the conversation_title utility runs once the
* user stops interacting with the choice list.
* @param {string} guid - The ranking conversation GUID.
*/
schedule_ranking_title_update(guid) {
if (!guid) return;
if (this.#ranking_title_timer) {
clearTimeout(this.#ranking_title_timer);
}
this.#ranking_title_timer = setTimeout(() => {
this.#ranking_title_timer = null;
void this.update_ranking_title(guid);
}, 3000);
}
/**
* Runs the conversation_title utility on the choice list when its hash
* differs from the one the current title was generated from, and applies
* the returned title to the ranking conversation.
* @param {string} guid - The ranking conversation GUID.
*/
async update_ranking_title(guid) {
if (this.#ranking_title_timer) {
clearTimeout(this.#ranking_title_timer);
this.#ranking_title_timer = null;
}
const conversation = this.app.storage.get_conversation(guid);
if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_RANKING) return;
const items = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
const text = items
.map(item => (item?.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n');
const hash = hash_text(text);
if (!text || hash === conversation[this.app.storage.KEY_CONVERSATION_CHOICES_HASH]) return;
conversation[this.app.storage.KEY_CONVERSATION_CHOICES_HASH] = hash;
this.app.storage.save_conversation(guid, conversation);
try {
const response = await this.app.api.evaluate_utility(text.slice(0, 4000), 'conversation_title');
const title = (response?.explanation || '').trim();
if (!title || response?.evaluation === false) return;
this.apply_document_title(guid, title);
} catch (error) {
console.warn('Ranking title update failed:', error);
}
}
/**
* Schedules the sticky notes title update a few seconds after the board
* changed, so the conversation_title utility runs once on the final state.
* @param {string} guid - The sticky notes conversation GUID.
*/
schedule_sticky_title_update(guid) {
if (!guid) return;
if (this.#sticky_title_timer) {
clearTimeout(this.#sticky_title_timer);
}
this.#sticky_title_timer = setTimeout(() => {
this.#sticky_title_timer = null;
void this.update_sticky_title(guid);
}, 3000);
}
/**
* Runs the conversation_title utility on the notes when their hash
* differs from the one the current title was generated from, and applies
* the returned title to the sticky notes conversation.
* @param {string} guid - The sticky notes conversation GUID.
*/
async update_sticky_title(guid) {
if (this.#sticky_title_timer) {
clearTimeout(this.#sticky_title_timer);
this.#sticky_title_timer = null;
}
const conversation = this.app.storage.get_conversation(guid);
if (!conversation || conversation[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] || conversation[this.app.storage.KEY_CONVERSATION_TYPE] !== this.app.storage.CONVERSATION_TYPE_STICKY_NOTES) return;
const notes = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_STICKIES]) ? conversation[this.app.storage.KEY_CONVERSATION_STICKIES] : [];
const text = notes
.map(note => [note?.title, ...(note?.items || []).map(item => item?.text)].join(' ').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n');
const hash = hash_text(text);
if (!text || hash === conversation[this.app.storage.KEY_CONVERSATION_STICKIES_HASH]) return;
conversation[this.app.storage.KEY_CONVERSATION_STICKIES_HASH] = hash;
this.app.storage.save_conversation(guid, conversation);
try {
const response = await this.app.api.evaluate_utility(text.slice(0, 4000), 'conversation_title');
const title = (response?.explanation || '').trim();
if (!title || response?.evaluation === false) return;
this.apply_document_title(guid, title);
} catch (error) {
console.warn('Sticky notes title update failed:', error);
}
}
/**
* Displays the progress indicator UI and stop button during request processing.
* @param {string|null} [guid=null] - Optional conversation GUID.
*/
show_progress_ui(guid = null) {
setElementDisplay(getEl('div-prompt-input'), 'none');
setElementDisplay(getEl('div-prompt-clarification'), 'none');
const progressDiv = getEl('div-response-progress');
if (!progressDiv) return;
progressDiv.innerHTML = '';
setElementDisplay(progressDiv, 'flex');
const current_guid = guid || this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const bytes = this.get_pending_thinking_bytes(current_guid);
const text = document.createElement('span');
text.id = 'span-progress-text';
text.textContent = bytes > 0 ? `Processing response... (${bytes})` : 'Processing response...';
progressDiv.appendChild(text);
if (this.show_chunks_enabled()) {
const tokenSpan = document.createElement('span');
tokenSpan.id = 'span-progress-tokens';
tokenSpan.className = 'span-progress-tokens';
progressDiv.appendChild(tokenSpan);
}
const stopBtn = document.createElement('button');
stopBtn.id = 'btn-stop-response';
stopBtn.textContent = 'Stop';
stopBtn.className = 'as-icon';
stopBtn.style.float = 'right';
stopBtn.onclick = () => this.stop_response(current_guid);
progressDiv.appendChild(stopBtn);
if (this.app.btn_send) {
this.app.btn_send.disabled = true;
this.app.btn_send.textContent = '...';
}
}
/**
* Updates the response progress label with received byte count.
* @param {number} bytes - Bytes received.
* @param {string} [guid] - Conversation GUID.
*/
update_progress(bytes, guid) {
const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
if (guid && guid !== current_guid) return;
const text = getEl('span-progress-text');
if (text) {
const displayBytes = (bytes !== undefined && bytes !== null && bytes > 0)
? bytes
: this.get_pending_thinking_bytes(guid || current_guid);
text.textContent = displayBytes > 0 ? `Processing response... (${displayBytes})` : 'Processing response...';
}
}
/**
* Renders the streamed token fade for the selected conversation's progress indicator.
* @param {string} [guid] - Conversation GUID whose tokens changed.
*/
update_token_display(guid) {
if (!this.show_chunks_enabled()) return;
const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
if (!guid || guid !== current_guid) return;
render_token_fade(getEl('span-progress-tokens'), this.app.api.get_tokens(guid));
}
/**
* 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');
if (this.app.btn_send) {
this.app.btn_send.textContent = 'Send';
this.app.btn_send.disabled = !this.app.validate_prompt();
}
}
/**
* Acquires a screen wake lock and initiates silent audio context to prevent background throttling.
* Only runs when both experimental features and background keep-alive are enabled.
* @returns {Promise<void>}
*/
async start_background_keep_alive() {
const config = this.app.storage.get_app_config();
const experimental_features_enabled = config[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
const keep_alive_enabled = config[this.app.storage.KEY_CONFIG_BACKGROUND_KEEP_ALIVE] || false;
if (!experimental_features_enabled || !keep_alive_enabled) return;
try {
if ('wakeLock' in navigator) {
this.#wakeLock = await navigator.wakeLock.request('screen');
}
} catch (err) {
console.warn('Wake Lock request failed:', err);
}
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 only when no active requests remain.
*/
stop_background_keep_alive() {
if (this.app.api && this.app.api.is_request_active()) {
return;
}
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 for the specified GUID (or currently selected conversation) and resets UI.
* @param {string} [targetGuid] - Optional GUID to abort.
*/
stop_response(targetGuid) {
const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const guid = targetGuid || selected_guid;
let stoppedPrompt = '';
if (guid) {
const conversation = this.app.storage.get_conversation(guid);
const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
if (Array.isArray(history) && history.length > 0 && history[history.length - 1].pending) {
const pendingItem = history.pop();
if (pendingItem && pendingItem.query) {
stoppedPrompt = pendingItem.query;
}
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(guid, conversation);
}
if (stoppedPrompt) {
this.app.prompt_drafts[guid] = stoppedPrompt;
} else if (this.app.prompt_drafts[guid]) {
stoppedPrompt = this.app.prompt_drafts[guid];
}
this.app.api.abort_request(guid);
} else {
this.app.api.abort_all();
}
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('tinai:stop', {
detail: { guid, prompt: stoppedPrompt }
}));
window.dispatchEvent(new CustomEvent('tinai:request-stop', {
detail: { guid, prompt: stoppedPrompt }
}));
}
this.stop_background_keep_alive();
this.update_conversation_loading_indicators(guid, false);
if (!guid || guid === selected_guid) {
this.hide_progress_ui();
if (this.app.prompt) {
this.app.prompt.value = stoppedPrompt || this.app.prompt_drafts[guid] || '';
this.app.resize_prompt_textarea();
this.app.validate_prompt();
this.app.prompt.focus();
}
if (this.app.btn_send) {
this.app.btn_send.textContent = 'Send';
this.app.btn_send.disabled = !this.app.validate_prompt();
}
this.app.on_conversation_updated(false);
} else {
this.app.content_list?.on_app_index_updated?.();
this.app.scratchpad_index?.on_conversation_index_updated?.();
}
}
/**
* Appends streamed thinking chunks to the currently pending history item for a specific conversation.
* @param {string|Array<string>} chunk - Streamed thinking text snippet.
* @param {string} [targetGuid] - Conversation GUID.
*/
send_thinking(chunk, targetGuid) {
const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
const guid = targetGuid || current_guid;
if (!guid) return;
const conversation = this.app.storage.get_conversation(guid);
const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
if (!history || history.length === 0) return;
let pendingItem = history[history.length - 1];
if (!pendingItem || !pendingItem.pending) {
pendingItem = history.find(item => item && item.pending);
if (!pendingItem) return;
}
if (!Array.isArray(pendingItem.thinking)) {
pendingItem.thinking = pendingItem.thinking ? [pendingItem.thinking] : [];
}
if (Array.isArray(chunk)) {
chunk.forEach(c => {
if (typeof c === 'string' && c.trim().length > 0) {
pendingItem.thinking.push(...c.split('\n').map(l => l.trim()).filter(l => l.length > 0));
}
});
} else if (typeof chunk === 'string') {
const sublines = chunk.split('\n').map(l => l.trim()).filter(l => l.length > 0);
if (sublines.length > 0) {
pendingItem.thinking.push(...sublines);
}
}
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(guid, conversation);
if (guid === current_guid) {
const bytes = this.get_pending_thinking_bytes(guid);
const text = getEl('span-progress-text');
if (text && bytes > 0) {
text.textContent = `Processing response... (${bytes})`;
}
const lastIndex = history.indexOf(pendingItem);
let thinkingContainer = this.app.div_response?.querySelector(`.div-thinking-content[data-index="${lastIndex}"]`);
if (!thinkingContainer) {
const allContainers = this.app.div_response?.querySelectorAll('.div-thinking-content');
if (allContainers && allContainers.length > 0) {
thinkingContainer = allContainers[allContainers.length - 1];
}
}
if (thinkingContainer) {
const textDiv = thinkingContainer.querySelector('.div-thinking-text');
if (textDiv) {
const lines = pendingItem.thinking;
append_following_bottom(textDiv, () => {
if (lines.length > 0) {
textDiv.innerHTML = lines.map(line => formatThinkingLineHtml(line)).join('');
lines.forEach(line => handleInteractiveRequest(line, guid, thinkingContainer, this.app));
}
if (!thinkingContainer.classList.contains('thinking-expanded') && !thinkingContainer.classList.contains('thinking-collapsed')) {
thinkingContainer.classList.add('thinking-expanded');
textDiv.style.display = 'block';
} else if (thinkingContainer.classList.contains('thinking-expanded')) {
textDiv.style.display = 'block';
}
});
}
thinkingContainer.dispatchEvent(new CustomEvent('thinking-updated', {
bubbles: true,
detail: { guid, index: lastIndex, thinking: pendingItem.thinking, chunk }
}));
} else {
this.app.on_conversation_updated(false);
}
}
}
/**
* Callback handling API execution failure or cancellation.
* @param {Object} response - The API error response.
* @param {string} [targetGuid] - Conversation GUID.
*/
send_failure(response, targetGuid) {
this.stop_background_keep_alive();
const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const guid = targetGuid || current_guid;
this.update_conversation_loading_indicators(guid, false);
let stoppedPrompt = '';
if (guid) {
const conversation = this.app.storage.get_conversation(guid);
const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
if (history && history.length > 0 && history[history.length - 1].pending) {
const pendingItem = history.pop();
if (pendingItem && pendingItem.query) {
stoppedPrompt = pendingItem.query;
}
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
this.app.storage.save_conversation(guid, conversation);
}
}
if (stoppedPrompt) {
this.app.prompt_drafts[guid] = stoppedPrompt;
}
if (current_guid === guid || !current_guid) {
this.hide_progress_ui();
if (this.app.prompt) {
if (stoppedPrompt || this.app.prompt_drafts[guid]) {
this.app.prompt.value = stoppedPrompt || this.app.prompt_drafts[guid] || '';
}
this.app.resize_prompt_textarea();
this.app.validate_prompt();
this.app.prompt.focus();
}
if (this.app.btn_send) {
this.app.btn_send.textContent = 'Send';
this.app.btn_send.disabled = !this.app.validate_prompt();
}
this.app.on_conversation_updated(false);
if (response && (response.status === 'aborted' || response.name === 'AbortError')) {
return;
}
const [title, message] = this.describe_api_failure(response);
void customAlert(title, message);
} else {
this.app.content_list?.on_app_index_updated?.();
this.app.scratchpad_index?.on_conversation_index_updated?.();
}
}
/**
* Builds the alert dialog for an API call failure, clearly separating
* provider-side timeouts, provider API errors, and TinAI-side failures.
* @param {Object} response - The API error response.
* @returns {string[]} Dialog title and message.
*/
describe_api_failure(response) {
const provider = response?.provider || 'Provider';
const detail = (response?.raw_response || '').trim();
switch (response?.error_type) {
case 'provider_timeout':
return [`${provider}: Provider Timed Out`, detail || 'The provider did not respond within the time limit.'];
case 'provider_error':
return [`${provider}: Provider Error`, response?.error || detail || 'The provider API reported a failure.'];
case 'tinai_timeout':
return ['TinAI API Timed Out', response?.error && response.error !== 'TinAI API timed out'
? response.error
: 'The request ran out of time on TinAI\'s server before the provider finished.'];
case 'tinai_error':
return ['TinAI API Error', response?.error || 'TinAI\'s server failed while handling the request.'];
case 'insufficient_balance':
return ['Insufficient Balance', response?.error || 'Your account balance is exhausted.'];
}
return ['Error', response?.error || response?.message || 'Something went wrong while communicating with the server.'];
}
/**
* Processes successful API responses, updates state, and renders the updated conversation.
* @param {ChatResponse} response - The API response object.
* @param {string} originalQuery - The original user prompt query.
* @param {string} [targetGuid] - The conversation GUID the response belongs to.
*/
send_success(response, originalQuery, targetGuid) {
this.stop_background_keep_alive();
this.app.play_alert_sound();
const current_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
const selected_guid = targetGuid || current_guid;
this.update_conversation_loading_indicators(selected_guid, false);
if (selected_guid) {
delete this.app.prompt_drafts[selected_guid];
}
delete this.app.prompt_drafts[''];
if (current_guid === selected_guid || !current_guid) {
this.hide_progress_ui();
if (this.app.btn_send) {
this.app.btn_send.disabled = false;
this.app.btn_send.textContent = 'Send';
}
if (this.app.prompt) {
this.app.prompt.value = '';
this.app.resize_prompt_textarea();
this.app.validate_prompt();
this.app.prompt.focus();
}
}
if (!selected_guid) return;
let conversation = this.app.storage.get_conversation(selected_guid);
let history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY] || [];
let pendingThinking = null;
let pendingRevision = null;
if (history && history.length > 0 && history[history.length - 1].pending) {
const pendingItem = history.pop();
if (pendingItem && pendingItem.thinking) {
pendingThinking = pendingItem.thinking;
}
pendingRevision = pendingItem;
}
if (response.thinking) {
if (typeof response.thinking === 'string') {
response.thinking = response.thinking.split('\n').map(l => l.trim()).filter(l => l.length > 0);
} else if (Array.isArray(response.thinking)) {
response.thinking = response.thinking.flatMap(item => typeof item === 'string' ? item.split('\n').map(l => l.trim()).filter(l => l.length > 0) : item);
}
} else if (pendingThinking) {
response.thinking = pendingThinking;
}
// scratchpad revisions carry the item state the request edited: the
// response merges into that revision instead of replacing it
let completed = response;
if (pendingRevision && Array.isArray(pendingRevision.items)) {
completed = Object.assign({}, response, {
items: pendingRevision.items,
toc: Array.isArray(pendingRevision.toc) ? pendingRevision.toc : []
});
}
if (conversation && conversation[this.app.storage.KEY_CONVERSATION_TYPE] === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
const conv_title = String(completed.conversationTitle || '').trim();
if (conv_title) {
conversation[this.app.storage.KEY_CONVERSATION_TITLE] = conv_title;
completed.conversationTitle = conv_title;
} else {
this.schedule_scratchpad_title_update(selected_guid);
}
} else if (completed.title || completed.conversationTitle) {
conversation[this.app.storage.KEY_CONVERSATION_TITLE] = completed.title || completed.conversationTitle;
}
if (completed.summary || completed.conversationSummary) {
conversation[this.app.storage.KEY_CONVERSATION_SUMMARY] = completed.summary || completed.conversationSummary;
}
completed.query = originalQuery;
history.push(completed);
conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
if (completed.topics) {
conversation[this.app.storage.KEY_CONVERSATION_TOPICS] = completed.topics;
}
if (completed.considerations) {
conversation[this.app.storage.KEY_CONVERSATION_CONSIDERATIONS] = completed.considerations;
}
this.app.storage.save_conversation(selected_guid, conversation);
this.app.storage.update_app_index(selected_guid);
if (current_guid === selected_guid || !current_guid) {
const wasFirstCompletedEntry = (history.length === 1);
if (wasFirstCompletedEntry && window.innerWidth > this.app.BREAKPOINT_MOBILE && !this.app.state_i_open) {
if (!this.app.state_v_open) {
this.app.state_i_open = true;
this.app.apply_panels_layout();
}
}
this.app.on_conversation_updated();
if (this.app.prompt) {
this.app.prompt.focus();
}
const app_defaults = this.app.storage.get_app_defaults();
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 (auto_run && completed.proposed_query && completed.proposed_query.trim().length > 0) {
setTimeout(() => {
const active_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (active_guid === selected_guid) {
this.send_suggested_query(completed.proposed_query);
}
}, 1000);
}
} else {
this.app.content_list?.on_app_index_updated?.();
this.app.scratchpad_index?.on_conversation_index_updated?.();
}
}
/**
* Populates prompt textarea with a suggested query and initiates request execution.
* @param {string} query - The proposed query string.
*/
send_suggested_query(query) {
if (this.app.prompt) {
this.app.prompt.value = query;
const config = this.app.storage.get_app_config();
const current_guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
this.app.prompt_drafts[current_guid] = query;
this.app.resize_prompt_textarea();
this.app.validate_prompt();
this.send();
}
}
}
/**
* Computes a compact hash of a text string, used to detect choice changes.
* @param {string} text - The text to hash.
* @returns {string} The hash.
*/
function hash_text(text) {
let h = 5381;
for (let i = 0; i < text.length; i++) {
h = ((h << 5) + h + text.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(36) + ':' + text.length.toString(36);
}
export default ResponsePipeline;
response-pipeline.js
×
Type: Web, text/plain
55.19 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/plain
55.19 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File