-
❓ .gitignoreUnknown, 37 B⬇
-
❓ README.mdUnknown, 2.94 kB⬇
-
🌐 admin_panel.phpWeb, 1.54 kB⬇
-
🌐 api.jsWeb, 22.21 kB⬇
-
🌐 api.phpWeb, 9.62 kB⬇
-
🌐 app.jsWeb, 61.42 kB⬇
-
🌐 attachments.jsWeb, 7.30 kB⬇
-
🌐 base.cssWeb, 2.23 kB⬇
-
🌐 choices-editor.jsWeb, 28.11 kB⬇
-
🌐 choices.cssWeb, 1.14 kB⬇
-
🌐 content.jsWeb, 31.08 kB⬇
-
🌐 context.jsWeb, 9.26 kB⬇
-
🌐 conversation-index.jsWeb, 10.74 kB⬇
-
🌐 conversation.cssWeb, 16.33 kB⬇
-
🌐 conversation.jsWeb, 1.11 kB⬇
-
🌐 dialogs.cssWeb, 11.43 kB⬇
-
🌐 diction.jsWeb, 948 B⬇
-
🌐 document-index.jsWeb, 1.04 kB⬇
-
🌐 document-model.jsWeb, 12.88 kB⬇
-
🌐 document.cssWeb, 6.74 kB⬇
-
🌐 document.jsWeb, 44.96 kB⬇
-
🌐 dynamic.phpWeb, 533 B⬇
-
📷 favicon-lg.pngImage, 9.11 kB⬇
-
📷 favicon.pngImage, 3.12 kB⬇
-
🌐 index-lists.cssWeb, 4.77 kB⬇
-
🌐 index.phpWeb, 49.72 kB⬇
-
🌐 indexjs.jsWeb, 377 B⬇
-
🌐 instant-answer.cssWeb, 5.17 kB⬇
-
🌐 layout.cssWeb, 11.37 kB⬇
-
🌐 login.phpWeb, 997 B⬇
-
❓ manifest.webmanifestUnknown, 262 B⬇
-
🌐 memory.jsWeb, 407 B⬇
-
🌐 mermaid.cssWeb, 6.16 kB⬇
-
🌐 model-presets.jsonWeb, 5.51 kB⬇
-
🌐 models.jsonWeb, 8.05 kB⬇
-
🌐 notes.cssWeb, 11.97 kB⬇
-
🌐 overlays.cssWeb, 13.97 kB⬇
-
⚠️ php.iniOS File, 424 B⬇
-
🌐 rankings-view.jsWeb, 5.67 kB⬇
-
🌐 rankings.cssWeb, 3.35 kB⬇
-
🌐 scratchpad-conversation.jsWeb, 4.87 kB⬇
-
🌐 scratchpad-index.jsWeb, 15.55 kB⬇
-
🌐 scratchpad-model.jsWeb, 13.29 kB⬇
-
🌐 scratchpad.cssWeb, 3.12 kB⬇
-
🌐 settings.cssWeb, 4.65 kB⬇
-
🌐 shared-view.cssWeb, 3.48 kB⬇
-
🌐 shared.cssWeb, 16.26 kB⬇
-
🌐 shared.jsWeb, 14.63 kB⬇
-
🌐 shared.phpWeb, 3.28 kB⬇
-
🌐 sticky-notes-editor.jsWeb, 64.84 kB⬇
-
🌐 storage.jsWeb, 36.73 kB⬇
-
🌐 style.cssWeb, 12.06 kB⬇
-
🌐 sw.jsWeb, 1.69 kB⬇
-
🌐 sync.jsWeb, 19.02 kB⬇
-
🌐 tools-index.jsWeb, 15.00 kB⬇
-
🌐 users.jsWeb, 49.86 kB⬇
-
🌐 utility-models.jsonWeb, 1.04 kB⬇
-
🌐 voice-models.jsonWeb, 4.81 kB⬇
import { getEl, strip_tags, contains_focus } from './util/dom-utils.js';
import { create_reorder_drag } from './util/drag-reorder.js';
import { escapeHtml } from './util/format-utils.js';
import { cost_block_html, formatCostSummary } from './util/conversation-format-utils.js';
import { replayInteractiveRequests } from './util/interactive-tools.js';
import Api from './api.js';
import ThinkingPanelBase from './util/thinking-panel-base.js';
/**
* Class ChoicesEditor
* Plain-text editable item list for the Choices tab of Ranking conversations.
* Reuses the document editor's item classes and interaction model: insert
* buttons between items, grip dragging to reorder, Enter to split or append,
* Shift+Enter for a line break within an item, and appending by clicking
* below the last item. It has no toolbars and no block types.
*
* Like the document editor, it owns a ThinkingPanelBase activity drawer
* and sends prompts to the rankings endpoint, where the model reads and edits
* the choice items through the choice tools.
*/
class ChoicesEditor {
#items = [];
#root = null;
#container = null;
#on_change = null;
#save_timer = null;
#active_index = -1;
#suppress_focusout = false;
#drag = create_reorder_drag({
draggable_of: (e) => {
const grip = e.target.closest('.span-document-gripper');
return grip?.closest('.div-document-item') || null;
},
state_of: (el) => {
const index = parseInt(el.dataset.index, 10);
return Number.isInteger(index) ? { index } : null;
},
target_of: (x, y) => document.elementFromPoint(x, y)?.closest('.div-document-insert') || null,
chrome_of: () => [[this.#root, 'dragging-items']],
scroll_el_of: () => this.#container,
commit: (drag) => {
const position = parseInt(drag.target_el?.dataset.position || '', 10);
if (!Number.isInteger(position)) return;
const at = Math.max(0, Math.min(position, this.#items.length));
if (at === drag.index || at === drag.index + 1) return;
const [moved] = this.#items.splice(drag.index, 1);
this.#items.splice(at > drag.index ? at - 1 : at, 0, moved);
this.#rerender();
this.#save_now();
}
});
#api = new Api();
#panel = new ThinkingPanelBase({
on_highlight: (guid) => this.#set_item_highlight(guid),
replay: (guid) => {
if (this.#app) {
replayInteractiveRequests(getEl('div-choices-thinking-content'), guid, this.#app);
}
}
}, {
drawer_selector: '#div-choices-thinking',
content_id: 'div-choices-thinking-content',
clear_id: 'btn-choices-thinking-clear',
prev_id: 'btn-choices-thinking-prev',
next_id: 'btn-choices-thinking-next'
});
#panel_bound = false;
#app = null;
#content_guid = null;
#asking_guids = new Set();
#highlighted_item = null;
#suggesting = false;
#suggest_button = null;
/**
* Returns whether the editor currently contains the keyboard focus.
* @returns {boolean} True when a child of the editor has focus.
*/
contains_focus() {
return contains_focus(this.#root);
}
/**
* Returns whether the stored items match the editor's current state.
* @param {Array} items - The stored choice items.
* @returns {boolean} True when both hold the same content.
*/
matches_items(items) {
return JSON.stringify(Array.isArray(items) ? items : []) === JSON.stringify(this.#serialize());
}
/**
* Renders the editor: one editable text block per choice item.
* @param {Array} [items=[]] - The stored choice items.
* @returns {string} The editor HTML.
*/
render(items = []) {
this.#items = (Array.isArray(items) ? items : []).map(item => this.#normalize_item(item));
if (this.#items.length === 0) {
this.#items = [this.#new_item('')];
}
this.#active_index = -1;
return `
<div class="div-choices-editor">
<div class="div-document-items">${this.#items_html()}</div>
</div>
`;
}
/**
* Wires the delegated editing events and the change callback. Listeners
* bind once per container; the item root is re-resolved on every call
* since rebuilds replace it.
* @param {HTMLElement} container - The container the editor renders into.
* @param {Function|null} [on_change=null] - Called with the items array after edits.
*/
attach(container, on_change = null) {
this.#on_change = on_change;
if (!container) return;
if (container !== this.#container) {
this.#container = container;
this.#bind_container_listeners(container);
}
this.#root = container.querySelector('.div-document-items');
if (!this.#panel_bound) {
const choices_container = getEl('div-choices-container');
if (choices_container) {
this.#panel.bind(choices_container);
this.#panel_bound = true;
}
window.addEventListener('beforeunload', () => this.#save_now());
}
}
/**
* Switches the thinking panel to the given conversation and remembers the
* app instance for the panel's storage and interactive callbacks.
* @param {string} guid - The conversation guid.
* @param {object|null} [app=null] - The application instance.
* @returns {boolean} Whether the panel switched.
*/
set_active_guid(guid, app = null) {
if (app) this.#app = app;
this.#panel.set_app(app);
if (guid !== this.#content_guid) {
if (this.#save_timer) this.#save_now();
}
this.#content_guid = guid;
return this.#panel.set_active_guid(guid);
}
/**
* Shows or hides the thinking drawer for the current surface.
* @param {boolean} visible - Whether the drawer is available.
*/
set_drawer_visible(visible) {
this.#panel.set_visible(visible);
}
//region Request Lifecycle
/**
* Sends the prompt with the choice list's structured content to the
* rankings endpoint, streaming reasoning into the thinking panel and
* appending the final text output at the end. The panel slides open for
* the request.
* @param {string} prompt - The user's prompt.
* @param {object} [options=null] - Optional request options.
* @param {string} [options.model_key=''] - The conversation's selected model key.
* @param {object} [options.meta_context=null] - Optional meta context (e.g. { search: true }).
* @param {App|null} [options.app=null] - The application instance, required for interactive request handling.
* @param {string} [options.guid=''] - The conversation guid, used for interactive request tracking.
* @param {Function|null} [options.on_done=null] - Called after the final output is appended.
* @param {Function|null} [options.on_error=null] - Called when the request failed.
*/
ask(prompt, options = {}) {
const target_guid = options.guid || '';
if (this.#asking_guids.has(target_guid)) return false;
this.#asking_guids.add(target_guid);
this.#panel.begin_run(target_guid);
// the app's shared api instance keeps the request visible to the
// progress UI, the stop button and the loading indicators
const api = options.app?.api || this.#api;
api.post(
{
prompt: prompt,
items: this.get_content(),
model_key: options.model_key || '',
thinking: options.thinking || '',
meta_context: options.meta_context || {},
local_thinking: true
},
(response) => this.#complete_run(target_guid, response, options),
(error) => this.#fail_run(target_guid, error, options),
'rankings',
(text) => this.#panel.stream_line(target_guid, text, options.app),
(bytes) => options.app?.response_pipeline?.update_progress(bytes, target_guid),
target_guid
);
}
/**
* Finishes a run on success: ends the streaming block and adds the
* findings or the text output plus the cost summary through the shared
* panel lifecycle. Any choice edits the model made flow through the
* change callback, which schedules the conversation title update.
* @param {string} guid - The conversation guid the run belongs to.
* @param {Object} response - The endpoint response.
* @param {object} options - The ask options.
*/
#complete_run(guid, response, options) {
this.#asking_guids.delete(guid);
if (!this.#panel.finish_run(guid, response)) {
options.on_error?.();
return;
}
options.on_done?.();
}
/**
* Finishes a run after a transport failure: ends the streaming block and
* adds the error output through the shared panel lifecycle.
* @param {string} guid - The conversation guid the run belongs to.
* @param {Object} error - The transport error.
* @param {object} options - The ask options.
*/
#fail_run(guid, error, options) {
this.#asking_guids.delete(guid);
this.#panel.fail_run(guid, error, 'Ranking request failed.');
options.on_error?.();
}
//endregion
//region Rank Action
/**
* Sends the rank request: the choice list is re-ordered per relevance to
* the prompt by the ranking endpoint, streaming reasoning into the
* thinking panel. The choices themselves are never modified. The
* structured result is delivered through options.on_result.
* @param {string} prompt - The user's ranking prompt.
* @param {object} [options=null] - Optional request options.
* @param {string} [options.model_key=''] - The conversation's selected model key.
* @param {object} [options.meta_context=null] - Optional meta context (e.g. { search: true }).
* @param {boolean} [options.show_suggested=false] - Whether the conversation asks for suggested follow-ups.
* @param {boolean} [options.show_related=false] - Whether the conversation asks for related topics.
* @param {App|null} [options.app=null] - The application instance, required for progress and streaming.
* @param {string} [options.guid=''] - The conversation guid.
* @param {Function|null} [options.on_result=null] - Called with the structured ranking response.
* @param {Function|null} [options.on_done=null] - Called after the panel is finalized.
* @param {Function|null} [options.on_error=null] - Called when the request failed.
* @returns {boolean} False when a request for this conversation is already in flight.
*/
rank(prompt, options = {}) {
const target_guid = options.guid || '';
if (this.#asking_guids.has(target_guid)) return false;
this.#asking_guids.add(target_guid);
this.#panel.begin_run(target_guid);
const api = options.app?.api || this.#api;
api.post(
{
prompt: prompt,
items: this.get_content(),
model_key: options.model_key || '',
thinking: options.thinking || '',
meta_context: options.meta_context || {},
show_suggested: !!options.show_suggested,
show_related: !!options.show_related,
local_thinking: true
},
(response) => this.#complete_rank_run(target_guid, response, options),
(error) => this.#fail_run(target_guid, error, options),
'rank',
(text) => this.#panel.stream_line(target_guid, text, options.app),
(bytes) => options.app?.response_pipeline?.update_progress(bytes, target_guid),
target_guid
);
}
/**
* Finishes a rank run on success: ends the streaming block, appends the
* reply html and the cost summary, persists the panel log, refreshes the
* conversation title when the choices changed during the run, and
* delivers the structured result.
* @param {string} guid - The conversation guid the run belongs to.
* @param {Object} response - The endpoint response.
* @param {object} options - The rank options.
*/
#complete_rank_run(guid, response, options) {
this.#asking_guids.delete(guid);
this.#panel.end_run(guid);
if (response && response.error) {
this.#panel.set_output(guid, 'ERROR: ' + response.error);
this.#panel.persist(guid);
options.on_error?.();
return;
}
const reply = String(response?.reply || '').trim();
if (reply) {
this.#panel.append_html(guid, '<div class="div-document-thinking-output">' + reply + '</div>');
} else {
this.#panel.set_output(guid, 'No ranking returned.');
}
this.#panel.append_html(guid, cost_block_html(formatCostSummary(response)));
this.#panel.persist(guid);
void options.app?.response_pipeline?.update_ranking_title(guid);
options.on_result?.(response);
options.on_done?.();
}
//endregion
//region Tool Edits
/**
* Applies a structured edit_choices edit to the items: add, replace, move,
* and remove, referenced by guids with the dynamic "last"/"first" values.
* Item values are plain text. Rerenders and saves after the edit.
* @param {Object} edit - The edit { action, guid, target_guid, position, item }.
* @returns {Object|null} { guid } of the affected item, or null when the edit cannot apply.
*/
apply_edit(edit) {
const action = String(edit?.action || '');
const position = edit?.position === 'above' ? 'above' : 'below';
const resolve = (guid) => {
const key = String(guid || '').trim().toLowerCase();
if (key === 'last') return this.#items.length - 1;
if (key === 'first') return 0;
return this.#items.findIndex(item => item.guid === guid);
};
if (action === 'add') {
const value = edit?.item?.value;
if (typeof value !== 'string') return null;
const anchor = resolve(edit?.guid);
const at = anchor === -1 ? this.#items.length : (position === 'above' ? anchor : anchor + 1);
this.#items.splice(at, 0, this.#new_item(this.#plain_to_html(value)));
this.#rerender();
this.#save_now();
return { guid: this.#items[at].guid };
}
const index = resolve(edit?.guid);
if (index === -1) return null;
if (action === 'replace') {
const value = edit?.item?.value;
if (typeof value !== 'string') return null;
this.#items[index].value = this.#plain_to_html(value);
this.#rerender();
this.#save_now();
return { guid: this.#items[index].guid };
}
if (action === 'remove') {
const [removed] = this.#items.splice(index, 1);
if (this.#items.length === 0) {
this.#items = [this.#new_item('')];
}
this.#rerender(Math.max(0, Math.min(index - 1, this.#items.length - 1)));
this.#save_now();
return { guid: removed?.guid || edit.guid };
}
if (action === 'move') {
const target = resolve(edit?.target_guid);
if (target === -1 || target === index) return null;
const [moved] = this.#items.splice(index, 1);
let at = target;
if (index < target) at -= 1;
if (position === 'below') at += 1;
this.#items.splice(at, 0, moved);
this.#rerender();
this.#save_now();
return { guid: moved.guid };
}
return null;
}
/**
* Returns the current choice items.
* @returns {Array<Object>} The choice item array.
*/
get_content() {
return this.#serialize();
}
/**
* Highlights the choice item referenced by a finding card.
* @param {string|null} guid - The item guid, or null to clear.
*/
#set_item_highlight(guid) {
if (this.#highlighted_item) {
this.#highlighted_item.classList.remove('document-item-highlighted');
this.#highlighted_item = null;
}
if (!guid || !this.#root) return;
const el = this.#root.querySelector(`.div-document-item[data-guid="${guid}"]`) || null;
if (!el) return;
el.classList.add('document-item-highlighted');
this.#highlighted_item = el;
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
//endregion
//region Editing Surface
/**
* Binds the delegated editing event listeners to the render container.
* @param {HTMLElement} container - The container the editor renders into.
*/
#bind_container_listeners(container) {
this.#drag.bind(container);
container.addEventListener('focusin', (e) => {
const value_el = e.target.closest('.div-document-value');
if (!value_el) return;
this.#active_index = parseInt(value_el.dataset.index, 10);
});
container.addEventListener('focusout', (e) => {
if (this.#suppress_focusout) return;
if (!container.contains(e.relatedTarget)) {
this.#save_now();
}
});
container.addEventListener('input', (e) => {
const value_el = e.target.closest('.div-document-value');
if (!value_el) return;
const index = parseInt(value_el.dataset.index, 10);
if (!this.#items[index]) return;
this.#items[index].value = value_el.innerHTML;
this.#sync_suggest_button(index, value_el);
this.#debounce_save();
});
container.addEventListener('keydown', (e) => {
const value_el = e.target.closest('.div-document-value');
if (!value_el) return;
const index = parseInt(value_el.dataset.index, 10);
if (e.key === 'ArrowUp' && index > 0 && this.#caret_at_start(value_el)) {
e.preventDefault();
this.#focus_item(index - 1);
} else if (e.key === 'ArrowDown' && index < this.#items.length - 1 && this.#caret_at_end(value_el)) {
e.preventDefault();
this.#focus_item(index + 1, true);
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.#split_or_append(index);
} else if (e.key === 'Backspace' && this.#is_empty_value(value_el)) {
e.preventDefault();
this.#remove_item(index);
}
});
container.addEventListener('click', (e) => {
// a drag release reports its click on a shared ancestor; swallow it
if (this.#drag.take_click_guard()) return;
const button = e.target.closest('button[data-action]');
if (button) {
if (button.dataset.action === 'insert-item') {
this.#insert_at(parseInt(button.dataset.position, 10));
} else if (button.dataset.action === 'suggest-item') {
this.suggest(parseInt(button.dataset.index, 10));
}
return;
}
// tapping empty space appends a new item to the end,
// unless the last item is already empty; focus it instead
if (e.target.closest('.div-document-items') && !e.target.closest('.div-document-item')) {
const last_index = this.#items.length - 1;
const last_value = last_index >= 0 ? this.#value_el(last_index) : null;
if (last_value && this.#is_empty_value(last_value)) {
this.#focus_item(last_index);
} else {
this.#append_item();
}
}
});
}
/**
* Builds the HTML for all editor blocks.
* @returns {string} The blocks HTML.
*/
#items_html() {
let html = `<button type="button" class="div-document-insert" data-action="insert-item" data-position="0" title="Insert item"></button>`;
this.#items.forEach((item, index) => {
const is_new = this.#is_new_choice(item.value);
html += `
<div class="div-document-item" data-index="${index}" data-guid="${item.guid}">
<div class="div-document-value" contenteditable="true" data-index="${index}">${item.value}</div>
<button type="button" class="as-icon div-choices-suggest" data-action="suggest-item" data-index="${index}" title="Suggest a choice"${is_new ? '' : ' style="display: none;"'}>🪄</button>
<div class="div-document-item-controls">
<span class="span-document-gripper" title="Reorder">‖</span>
</div>
</div>
`;
html += `<button type="button" class="div-document-insert" data-action="insert-item" data-position="${index + 1}" title="Insert item"></button>`;
});
return html;
}
//endregion
//region Item Actions
/**
* Inserts a new empty item at a position and focuses it.
* @param {number} position - The insertion index.
*/
#insert_at(position) {
if (!Number.isInteger(position) || position < 0 || position > this.#items.length) return;
this.#items.splice(position, 0, this.#new_item(''));
this.#rerender(position);
this.#save_now();
}
/**
* Inserts a new item after the focused one, splitting the content at the
* caret when content follows it.
* @param {number} index - The focused item position.
*/
#split_or_append(index) {
const item = this.#items[index];
if (!item) return;
const value_el = this.#value_el(index);
let after_html = '';
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && value_el && value_el.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
const after_range = range.cloneRange();
after_range.selectNodeContents(value_el);
after_range.setStart(range.endContainer, range.endOffset);
const holder = document.createElement('div');
holder.appendChild(after_range.extractContents());
after_html = holder.innerHTML;
item.value = value_el.innerHTML;
}
this.#items.splice(index + 1, 0, this.#new_item(after_html));
this.#rerender(index + 1);
this.#save_now();
}
/**
* Removes an item and focuses the end of the previous one. The list never
* empties: removing the last remaining item leaves one empty item.
* @param {number} index - The item position.
*/
#remove_item(index) {
this.#items.splice(index, 1);
if (this.#items.length === 0) {
this.#items = [this.#new_item('')];
}
this.#rerender(Math.max(0, Math.min(index - 1, this.#items.length - 1)));
this.#save_now();
}
/**
* Appends a new empty item to the end and focuses it.
*/
#append_item() {
this.#items.push(this.#new_item(''));
this.#rerender(this.#items.length - 1);
this.#save_now();
}
//endregion
//region Choice Suggestions
/**
* Returns whether a choice counts as "new": its plain text is shorter
* than two characters, so it is effectively an empty slot.
* @param {string} value - The choice's inline html.
* @returns {boolean} True when the choice is new.
*/
#is_new_choice(value) {
return strip_tags(String(value || '').replace(/<br\s*\/?>/gi, '')).trim().length < 2;
}
/**
* Toggles one item's wand button from the item's current text.
* @param {number} index - The item position.
* @param {HTMLElement} value_el - The item's editable element.
*/
#sync_suggest_button(index, value_el) {
const button = this.#root?.querySelector(`.div-choices-suggest[data-index="${index}"]`);
if (button) {
button.style.display = this.#is_new_choice(value_el.innerHTML) ? '' : 'none';
}
}
/**
* Suggests a choice for a new item: sends the list and the item's
* position to the suggest endpoint and fills the item with the returned
* choice. Reasoning streams into the thinking panel.
* @param {number} index - The target item position.
* @returns {boolean} False when a suggestion is already running or the item is not new.
*/
suggest(index) {
if (this.#suggesting) return false;
const item = this.#items[index];
if (!item || !this.#is_new_choice(item.value)) return false;
const app = this.#app;
const guid = this.#content_guid || '';
if (!app) return false;
this.#suggesting = true;
this.#suggest_button = this.#root?.querySelector(`.div-choices-suggest[data-index="${index}"]`) || null;
if (this.#suggest_button) this.#suggest_button.disabled = true;
this.#panel.begin_run(guid);
const { model_key, thinking_level } = app.response_pipeline.resolve_conversation_model();
const meta_context = app.response_pipeline.conversation_search_enabled() ? { search: true } : {};
const api = app.api || this.#api;
api.post(
{
items: this.get_content(),
position: index,
model_key: model_key,
thinking: thinking_level,
meta_context: meta_context,
local_thinking: true
},
(response) => this.#complete_suggest_run(index, guid, response),
(error) => this.#fail_suggest_run(guid, error),
'suggest',
(text) => this.#panel.stream_line(guid, text, app),
(bytes) => app.response_pipeline?.update_progress(bytes, guid),
guid
);
return true;
}
/**
* Finishes a suggestion on success: fills the target item with the
* returned choice, saves, and finalizes the panel through the shared
* cost block and persist.
* @param {number} index - The target item position.
* @param {string} guid - The conversation guid.
* @param {Object} response - The endpoint response.
*/
#complete_suggest_run(index, guid, response) {
this.#suggesting = false;
this.#panel.end_run(guid);
if (this.#suggest_button) this.#suggest_button.disabled = false;
if (response && response.error) {
this.#panel.set_output(guid, 'ERROR: ' + response.error);
this.#panel.persist(guid);
return;
}
const choice = String(response?.choice || '').trim();
if (!choice || !this.#items[index]) {
this.#panel.set_output(guid, 'No suggestion returned.');
this.#panel.persist(guid);
return;
}
this.#items[index].value = this.#plain_to_html(choice);
this.#rerender(index);
this.#save_now();
this.#panel.append_html(guid, cost_block_html(formatCostSummary(response)));
this.#panel.persist(guid);
}
/**
* Finishes a suggestion after a transport failure: ends the streaming
* block, adds the error output, persists the panel log.
* @param {string} guid - The conversation guid.
* @param {Object} error - The transport error.
*/
#fail_suggest_run(guid, error) {
this.#suggesting = false;
this.#panel.end_run(guid);
if (this.#suggest_button) this.#suggest_button.disabled = false;
this.#panel.set_output(guid, 'ERROR: ' + (error?.error || error?.message || 'Suggestion request failed.'));
this.#panel.persist(guid);
}
//endregion
//region Helpers
/**
* Normalizes a stored choice item, assigning a guid when missing.
* @param {Object} item - The stored item.
* @returns {Object} The normalized item.
*/
#normalize_item(item) {
return { value: item?.value || '', guid: item?.guid || crypto.randomUUID() };
}
/**
* Creates a new choice item with a fresh guid.
* @param {string} value - The item's inline html.
* @returns {Object} The item.
*/
#new_item(value) {
return { value: value, guid: crypto.randomUUID() };
}
/**
* Serializes the items back into conversation storage.
* @returns {Array<Object>} The item array.
*/
#serialize() {
return this.#items.map(item => ({ value: item.value, guid: item.guid }));
}
/**
* Converts a plain-text value into the editor's inline html: escaped text
* with newlines as line breaks.
* @param {string} value - The plain text value.
* @returns {string} The inline html.
*/
#plain_to_html(value) {
return escapeHtml(String(value || '')).replace(/\n/g, '<br>');
}
/**
* Returns the editable element of an item.
* @param {number} index - The item position.
* @returns {HTMLElement|null} The editable element.
*/
#value_el(index) {
return this.#root ? this.#root.querySelector(`.div-document-item[data-index="${index}"] .div-document-value`) : null;
}
/**
* Rebuilds the item list and restores focus.
* @param {number} [focus_index=-1] - The item to focus after the rebuild.
*/
#rerender(focus_index = -1) {
if (!this.#root) return;
this.#suppress_focusout = true;
this.#root.innerHTML = this.#items_html();
this.#suppress_focusout = false;
if (focus_index >= 0) {
this.#focus_item(focus_index);
}
}
/**
* Focuses an item's editable element and places the caret.
* @param {number} index - The item position.
* @param {boolean} [at_start=false] - Whether to place the caret at the start.
*/
#focus_item(index, at_start = false) {
const el = this.#value_el(index);
if (!el) return;
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(at_start);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Returns whether an item's content is empty.
* @param {HTMLElement} value_el - The editable element.
* @returns {boolean} True when the item holds no content.
*/
#is_empty_value(value_el) {
return value_el.innerHTML.replace(/<br\s*\/?>/gi, '').trim() === '';
}
/**
* Returns whether the caret sits at the very start of an item.
* @param {HTMLElement} value_el - The editable element.
* @returns {boolean} True when the caret is at the start.
*/
#caret_at_start(value_el) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
if (!range.collapsed) return false;
const probe = document.createRange();
probe.selectNodeContents(value_el);
probe.setEnd(range.startContainer, range.startOffset);
return probe.toString() === '';
}
/**
* Returns whether the caret sits at the very end of an item.
* @param {HTMLElement} value_el - The editable element.
* @returns {boolean} True when the caret is at the end.
*/
#caret_at_end(value_el) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
if (!range.collapsed) return false;
const probe = document.createRange();
probe.selectNodeContents(value_el);
probe.setStart(range.endContainer, range.endOffset);
return probe.toString() === '';
}
/**
* Schedules a debounced save of the choice items.
*/
#debounce_save() {
clearTimeout(this.#save_timer);
this.#save_timer = setTimeout(() => this.#save_now(), 400);
}
/**
* Saves the choice items immediately through the change callback.
*/
#save_now() {
clearTimeout(this.#save_timer);
this.#save_timer = null;
if (this.#on_change) {
this.#on_change(this.#serialize());
}
}
//endregion
}
export default ChoicesEditor;
choices-editor.js
×
Type: Web, text/x-c++
28.11 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/x-c++
28.11 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File