-
🌐 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⬇
/**
* Client-side framework for interactive tools that request input from the user.
* The server streams INPUTREQ: lines in the thinking stream; each line is parsed
* here and dispatched to a registered handler by tool name. Handlers render their
* UI (typically via the plugin dialog) and deliver the reply via submit().
*/
import { openPluginDialog } from './plugin-dialog.js';
import { showNotification } from './notification-stack.js';
import { apply_scratchpad_edit, apply_scratchpad_toc_edit } from './scratchpad-editor.js';
// Namespace import tolerates a stale cached dom-utils.js missing
// update_range_fill (see options-manager.js).
import * as dom_utils from './dom-utils.js';
/** @typedef {import('../app.js').default} App */
/**
* Context passed to an interactive tool handler.
* @typedef {Object} InteractiveToolContext
* @property {Object} data - Parsed INPUTREQ payload (request_id, name, friendly_name, params, expires_at).
* @property {HTMLElement} el - The request element inside the thinking block.
* @property {App} app - The application instance.
* @property {string} guid - Conversation guid the request belongs to.
* @property {function(Object): Promise<void>} submit - Submits the reply JSON to the server.
* @property {function(number): Promise<Object>} extend - Extends the deadline by the given seconds.
*/
/** @type {Record<string, function(InteractiveToolContext): void>} */
const handlers = {};
/** @type {Map<string, {guid: string, data: Object}>} */
const active = new Map();
/**
* Registers a client handler for an interactive tool name.
* @param {string} name - Server tool name (e.g. 'ask_user_choice').
* @param {function(InteractiveToolContext): void} handler - Handler that renders the input UI.
*/
export function registerInteractiveTool(name, handler) {
handlers[name] = handler;
}
/**
* Handles a single thinking line if it is an interactive input request.
* @param {string} line - Thinking line text.
* @param {string} guid - Conversation guid.
* @param {HTMLElement|null} container - The thinking block container to render controls into.
* @param {App} app - The application instance.
* @returns {boolean} True if the line was an input request (consumed either way).
*/
export function handleInteractiveRequest(line, guid, container, app) {
if (typeof line !== 'string' || !line.startsWith('INPUTREQ:')) {
return false;
}
let data = null;
try {
data = JSON.parse(line.substring(9).trim());
} catch (e) {
data = null;
}
if (!data || !data.request_id || active.has(data.request_id)) {
return true;
}
active.set(data.request_id, { guid: guid, data: data });
const el = container ? container.querySelector(`[data-request-id="${selectorEscape(data.request_id)}"]`) : null;
const handler = handlers[data.name];
if (typeof handler !== 'function') {
return true;
}
handler({
data: data,
el: el,
app: app,
guid: guid,
submit: (reply) => submitReply(app, data.request_id, el, reply),
extend: (seconds) => extendDeadline(app, data.request_id, seconds)
});
return true;
}
/**
* Re-dispatches interactive request handlers for unanswered request elements
* found in the given container. Used when panel content is restored from
* memory: the controls re-attach and the user can reopen the dialog manually.
* @param {HTMLElement} container - The container with restored request elements.
* @param {string} guid - Conversation guid the requests belong to.
* @param {App} app - The application instance.
*/
export function replayInteractiveRequests(container, guid, app) {
if (!container) return;
for (const el of container.querySelectorAll('[data-request-id]')) {
const request_id = el.dataset.requestId;
const entry = active.get(request_id);
if (!entry || el.dataset.inputState === 'answered' || el.dataset.inputState === 'expired') continue;
const handler = handlers[entry.data.name];
if (typeof handler !== 'function') continue;
handler({
data: entry.data,
el: el,
app: app,
guid: guid,
replay: true,
submit: (reply) => submitReply(app, request_id, el, reply),
extend: (seconds) => extendDeadline(app, request_id, seconds)
});
}
}
/**
* Submits the user's reply for an interactive request and updates the request element state.
* @param {App} app - The application instance.
* @param {string} requestId - Request guid.
* @param {HTMLElement} el - The request element.
* @param {Object} reply - Reply JSON validated and stored by the server.
* @returns {Promise<void>}
*/
async function submitReply(app, requestId, el, reply) {
setControlsBusy(el, true);
const response = await new Promise((resolve) => {
app.api.post(
{ action: 'submit', request_id: requestId, reply: reply },
(result) => resolve(result),
(error) => resolve({ error: error?.message || 'Submit failed' }),
'input'
);
});
if (response && response.success) {
markRequestState(el, 'answered', 'Answered');
} else {
setControlsBusy(el, false);
markRequestState(el, 'error', response?.error || 'Submit failed');
}
}
/**
* Requests a deadline extension for a pending request.
* @param {App} app - The application instance.
* @param {string} requestId - Request guid.
* @param {number} seconds - Seconds to add to the current deadline.
* @returns {Promise<Object>} Server response with the new expires_at, or an error.
*/
async function extendDeadline(app, requestId, seconds) {
return new Promise((resolve) => {
app.api.post(
{ action: 'extend', request_id: requestId, seconds: seconds },
(result) => resolve(result),
(error) => resolve({ error: error?.message || 'Extend failed' }),
'input'
);
});
}
/**
* Starts a countdown against the request deadline, expiring the controls when it passes.
* The countdown stops on its own once the element is marked answered.
* @param {number} expiresAt - Deadline as a unix timestamp in seconds.
* @param {HTMLElement} el - The request element.
* @param {function(void): void} [onExpire] - Called once when the deadline passes.
*/
function startCountdown(expiresAt, el, onExpire = null) {
if (!el) return;
const controls = el.querySelector('.div-input-request-controls');
if (!controls) return;
const span = document.createElement('span');
span.className = 'span-input-request-countdown';
controls.appendChild(span);
const timer = setInterval(() => {
if (el.dataset.inputState === 'answered') {
clearInterval(timer);
span.remove();
return;
}
const remaining = Math.max(0, Math.floor(expiresAt - Date.now() / 1000));
if (remaining <= 0) {
clearInterval(timer);
span.remove();
setControlsBusy(el, true);
markRequestState(el, 'expired', 'Expired');
if (typeof onExpire === 'function') {
onExpire();
}
return;
}
span.textContent = `${remaining}s`;
}, 1000);
}
/**
* Enables or disables all buttons inside the request controls.
* @param {HTMLElement} el - The request element.
* @param {boolean} busy - Whether controls should be disabled.
*/
function setControlsBusy(el, busy) {
if (!el) return;
el.querySelectorAll('button').forEach(btn => {
btn.disabled = busy;
});
}
/**
* Records and displays the state of a request (answered / expired / error).
* @param {HTMLElement} el - The request element.
* @param {string} state - State key stored on the element.
* @param {string} message - Short text shown next to the request.
*/
function markRequestState(el, state, message) {
if (!el) return;
el.dataset.inputState = state;
let span = el.querySelector('.span-input-request-state');
if (!span) {
span = document.createElement('span');
span.className = 'span-input-request-state';
const controls = el.querySelector('.div-input-request-controls');
if (controls) {
controls.appendChild(span);
} else {
el.appendChild(span);
}
}
span.textContent = message;
}
/**
* Escapes a value for use inside an attribute selector.
* @param {string} value - Raw value.
* @returns {string} Escaped value.
*/
function selectorEscape(value) {
return (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(value) : value.replace(/["\\]/g, '\\$&');
}
/**
* Sets up the shared dialog scaffolding for an interactive request: inline
* controls with a reopen button, a countdown that moves into the dialog header,
* and dialog reopen handling. The dialog content itself is built by the caller.
* @param {InteractiveToolContext} ctx - Request context.
* @param {string} reopenLabel - Label for the button that reopens a dismissed dialog.
* @param {function(function(): void): HTMLElement} buildDialogContent - Builds the
* dialog content; receives a closeDialog callback for closing it programmatically.
*/
function setupRequestDialog(ctx, reopenLabel, buildDialogContent) {
const { data, el } = ctx;
let dialogOpen = false;
let dialogHandle = null;
/**
* Moves the countdown span between the inline controls and the dialog header.
* @param {HTMLElement} target - Element to move the countdown span into.
*/
const moveCountdown = (target) => {
const span = el ? el.querySelector('.span-input-request-countdown') : null;
if (span) {
target.appendChild(span);
}
};
/**
* Closes the currently open dialog, if any.
*/
const closeDialog = () => {
dialogHandle?.close();
};
/**
* Opens the request dialog unless it is already open or the request is done.
*/
const openDialog = () => {
if (dialogOpen || el.dataset.inputState === 'answered' || el.dataset.inputState === 'expired') {
return;
}
dialogOpen = true;
reopenBtn.style.display = 'none';
dialogHandle = openPluginDialog((data.params || {}).title || 'Your input', buildDialogContent(closeDialog), () => {
dialogOpen = false;
dialogHandle = null;
moveCountdown(controls);
if (el.dataset.inputState !== 'answered' && el.dataset.inputState !== 'expired') {
reopenBtn.style.display = '';
}
});
if (dialogHandle.meta) {
moveCountdown(dialogHandle.meta);
}
};
const controls = el ? document.createElement('div') : null;
if (controls) {
controls.className = 'div-input-request-controls';
el.appendChild(controls);
}
const reopenBtn = document.createElement('button');
reopenBtn.className = 'btn-input-request-option';
reopenBtn.textContent = reopenLabel;
reopenBtn.style.display = 'none';
reopenBtn.onclick = () => openDialog();
if (controls) {
controls.appendChild(reopenBtn);
}
if (el) {
startCountdown(data.expires_at, el, closeDialog);
}
// with no inline element (headless processing) the dialog must open directly
if (!controls || !ctx.replay) {
openDialog();
} else {
reopenBtn.style.display = '';
}
}
/**
* ask_user_choice handler: opens a dialog with the title, prompt, and one button
* per option; submits the picked option.
* @param {InteractiveToolContext} ctx - Request context.
*/
function askUserChoice(ctx) {
const params = ctx.data.params || {};
const options = Array.isArray(params.options) ? params.options : [];
setupRequestDialog(ctx, 'Choose', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
const promptEl = document.createElement('p');
promptEl.textContent = params.prompt || '';
content.appendChild(promptEl);
options.forEach(option => {
const btn = document.createElement('button');
btn.className = 'btn-plugin-dialog-choice';
btn.textContent = option;
btn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit({ choice: option });
if (ctx.el.dataset.inputState === 'answered') {
closeDialog();
} else {
setControlsBusy(content, false);
}
};
content.appendChild(btn);
});
return content;
});
}
/**
* ask_user_text handler: opens a dialog with the title, prompt, and a text
* field; submits the entered text. Single-line requests render an input
* (with the optional html input type), multi-line requests render a
* self-expanding textarea. Optional min/max character counts are enforced
* client-side before submitting and validated again by the server.
* @param {InteractiveToolContext} ctx - Request context.
*/
function askUserText(ctx) {
const params = ctx.data.params || {};
const minChars = Number.isInteger(params.min_chars) ? params.min_chars : null;
const maxChars = Number.isInteger(params.max_chars) ? params.max_chars : null;
setupRequestDialog(ctx, 'Answer', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
const promptEl = document.createElement('p');
promptEl.textContent = params.prompt || '';
content.appendChild(promptEl);
let field;
if (params.single_line === false) {
field = document.createElement('textarea');
field.addEventListener('input', () => {
field.style.height = 'auto';
if (field.scrollHeight > 0) {
field.style.height = field.scrollHeight + 'px';
}
});
} else {
field = document.createElement('input');
field.type = (typeof params.input_type === 'string' && /^[a-z]{1,20}$/.test(params.input_type))
? params.input_type
: 'text';
}
field.className = 'prompt-style-input';
if (maxChars !== null) {
field.maxLength = maxChars;
}
content.appendChild(field);
const errorEl = document.createElement('span');
errorEl.className = 'span-input-request-state';
content.appendChild(errorEl);
const submitBtn = document.createElement('button');
submitBtn.textContent = 'Submit';
submitBtn.onclick = async () => {
const text = field.value.trim();
if (text === '' || (minChars !== null && text.length < minChars)) {
errorEl.textContent = minChars !== null
? `Please enter at least ${minChars} characters.`
: 'Please enter some text.';
return;
}
errorEl.textContent = '';
setControlsBusy(content, true);
await ctx.submit({ text: field.value });
if (ctx.el.dataset.inputState === 'answered') {
closeDialog();
} else {
setControlsBusy(content, false);
}
};
content.appendChild(submitBtn);
return content;
});
}
registerInteractiveTool('ask_user_choice', askUserChoice);
/**
* ask_user_multi_choice handler: opens a dialog with the title, prompt, and
* one checkbox per option; the submit button (label from submit_label) is
* enabled only while the number of checked options is within min_selections
* and max_selections. Submits the checked options.
* @param {InteractiveToolContext} ctx - Request context.
*/
function askUserMultiChoice(ctx) {
const params = ctx.data.params || {};
const options = Array.isArray(params.options) ? params.options : [];
const min = Number.isInteger(params.min_selections) ? params.min_selections : 1;
const max = Number.isInteger(params.max_selections) ? params.max_selections : options.length;
setupRequestDialog(ctx, 'Choose', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
const promptEl = document.createElement('p');
promptEl.textContent = params.prompt || '';
content.appendChild(promptEl);
const checkboxes = options.map(option => {
const label = document.createElement('label');
label.className = 'label-plugin-dialog-checkbox';
const box = document.createElement('input');
box.type = 'checkbox';
box.value = option;
box.addEventListener('change', () => updateSubmitState());
label.appendChild(box);
label.appendChild(document.createTextNode(option));
content.appendChild(label);
return box;
});
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-plugin-dialog-choice';
submitBtn.textContent = params.submit_label || 'Submit';
submitBtn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit({ choices: checkboxes.filter(box => box.checked).map(box => box.value) });
if (ctx.el.dataset.inputState === 'answered') {
closeDialog();
} else {
setControlsBusy(content, false);
}
};
content.appendChild(submitBtn);
/**
* Enables the submit button only when the checked count is in bounds.
*/
const updateSubmitState = () => {
const checked = checkboxes.filter(box => box.checked).length;
submitBtn.disabled = checked < min || checked > max;
};
updateSubmitState();
return content;
});
}
registerInteractiveTool('ask_user_multi_choice', askUserMultiChoice);
registerInteractiveTool('ask_user_text', askUserText);
/**
* ask_user_range handler: opens a dialog with the title, prompt, and a range
* slider between the requested bounds; submits the selected value with a live
* numeric readout next to the slider.
* @param {InteractiveToolContext} ctx - Request context.
*/
function askUserRange(ctx) {
const params = ctx.data.params || {};
const min = Number.isFinite(params.min) ? params.min : 0;
const max = Number.isFinite(params.max) ? params.max : 10;
const step = Number.isFinite(params.step) && params.step > 0 ? params.step : 1;
setupRequestDialog(ctx, 'Answer', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
const promptEl = document.createElement('p');
promptEl.textContent = params.prompt || '';
content.appendChild(promptEl);
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'input-plugin-dialog-range input-range-themed';
slider.min = min;
slider.max = max;
slider.step = step;
slider.value = min;
content.appendChild(slider);
const valueEl = document.createElement('span');
valueEl.className = 'span-plugin-dialog-range-value';
valueEl.textContent = String(min);
dom_utils.update_range_fill?.(slider);
slider.addEventListener('input', () => {
valueEl.textContent = slider.value;
dom_utils.update_range_fill?.(slider);
});
content.appendChild(valueEl);
const submitBtn = document.createElement('button');
submitBtn.textContent = 'Submit';
submitBtn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit({ value: parseFloat(slider.value) });
if (ctx.el.dataset.inputState === 'answered') {
closeDialog();
} else {
setControlsBusy(content, false);
}
};
content.appendChild(submitBtn);
return content;
});
}
registerInteractiveTool('ask_user_range', askUserRange);
/**
* edit_document handler: non-suggested edits are applied to the document
* immediately and auto-submitted; suggested edits open a confirmation dialog
* (title carries the suggestion counter) with the edit's description and the
* new item preview, and Accept/Reject buttons. The document after-state is
* submitted with the final request.
* @param {InteractiveToolContext} ctx - Request context.
*/
function editDocument(ctx) {
const params = ctx.data.params || {};
const edit = params.edit || {};
const buildReply = (result, accepted) => {
const reply = accepted === null
? { applied: !!result, guid: result?.guid || null }
: { accepted: accepted, applied: accepted === true && !!result, guid: result?.guid || null };
if (params.last) {
reply.document = ctx.app.document.get_content();
}
return reply;
};
if (params.type !== 'suggestion') {
const result = ctx.app.document.apply_edit(edit);
ctx.submit(buildReply(result, null));
return;
}
setupRequestDialog(ctx, 'Review Suggestion', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
if (edit.description) {
const descriptionEl = document.createElement('p');
descriptionEl.textContent = edit.description;
content.appendChild(descriptionEl);
}
if (edit.item && edit.item.value) {
const preview = document.createElement('div');
preview.className = 'div-edit-suggestion-preview';
preview.innerHTML = String(edit.item.value)
.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
.replace(/<\/?a\b[^>]*>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
content.appendChild(preview);
}
const errorEl = document.createElement('span');
errorEl.className = 'span-input-request-state';
content.appendChild(errorEl);
const buttons = document.createElement('div');
buttons.className = 'div-edit-suggestion-buttons';
const acceptBtn = document.createElement('button');
acceptBtn.className = 'btn-plugin-dialog-choice';
acceptBtn.textContent = 'Accept';
acceptBtn.onclick = async () => {
setControlsBusy(content, true);
const result = ctx.app.document.apply_edit(edit);
if (!result) {
setControlsBusy(content, false);
errorEl.textContent = 'The referenced item no longer exists.';
return;
}
await ctx.submit(buildReply(result, true));
closeDialog();
};
const rejectBtn = document.createElement('button');
rejectBtn.className = 'btn-plugin-dialog-choice';
rejectBtn.textContent = 'Reject';
rejectBtn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit(buildReply(null, false));
closeDialog();
};
buttons.append(acceptBtn, rejectBtn);
content.appendChild(buttons);
return content;
});
}
registerInteractiveTool('edit_document', editDocument);
/**
* edit_choices handler: non-suggested edits are applied to the choice list
* immediately and auto-submitted; suggested edits open a confirmation dialog
* (title carries the suggestion counter) with the edit's description and the
* plain-text item preview, and Accept/Reject buttons. The choice list
* after-state is submitted with the final request.
* @param {InteractiveToolContext} ctx - Request context.
*/
function editChoices(ctx) {
const params = ctx.data.params || {};
const edit = params.edit || {};
const buildReply = (result, accepted) => {
const reply = accepted === null
? { applied: !!result, guid: result?.guid || null }
: { accepted: accepted, applied: accepted === true && !!result, guid: result?.guid || null };
if (params.last) {
reply.choices = ctx.app.choices.get_content();
}
return reply;
};
if (params.type !== 'suggestion') {
const result = ctx.app.choices.apply_edit(edit);
ctx.submit(buildReply(result, null));
return;
}
setupRequestDialog(ctx, 'Review Suggestion', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
if (edit.description) {
const descriptionEl = document.createElement('p');
descriptionEl.textContent = edit.description;
content.appendChild(descriptionEl);
}
if (edit.item && edit.item.value) {
const preview = document.createElement('div');
preview.className = 'div-edit-suggestion-preview';
preview.textContent = String(edit.item.value);
content.appendChild(preview);
}
const errorEl = document.createElement('span');
errorEl.className = 'span-input-request-state';
content.appendChild(errorEl);
const buttons = document.createElement('div');
buttons.className = 'div-edit-suggestion-buttons';
const acceptBtn = document.createElement('button');
acceptBtn.className = 'btn-plugin-dialog-choice';
acceptBtn.textContent = 'Accept';
acceptBtn.onclick = async () => {
setControlsBusy(content, true);
const result = ctx.app.choices.apply_edit(edit);
if (!result) {
setControlsBusy(content, false);
errorEl.textContent = 'The referenced item no longer exists.';
return;
}
await ctx.submit(buildReply(result, true));
closeDialog();
};
const rejectBtn = document.createElement('button');
rejectBtn.className = 'btn-plugin-dialog-choice';
rejectBtn.textContent = 'Reject';
rejectBtn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit(buildReply(null, false));
closeDialog();
};
buttons.append(acceptBtn, rejectBtn);
content.appendChild(buttons);
return content;
});
}
registerInteractiveTool('edit_choices', editChoices);
/**
* edit_notes handler: non-suggested edits are applied to the sticky notes
* board immediately and auto-submitted; suggested edits open a confirmation
* dialog (title carries the suggestion counter) with the edit's description
* and a plain-text preview of the affected content, and Accept/Reject
* buttons. The board after-state is submitted with the final request.
* @param {InteractiveToolContext} ctx - Request context.
*/
function editNotes(ctx) {
const params = ctx.data.params || {};
const edit = params.edit || {};
const previewText = () => {
if (edit.note) {
return [edit.note.title, edit.note.text].filter(Boolean).join('\n');
}
if (edit.text) return edit.text;
if (Array.isArray(edit.tags)) return edit.tags.join(', ');
return edit.description || '';
};
const buildReply = (result, accepted) => {
const reply = accepted === null
? { applied: !!result, guid: result?.guid || null }
: { accepted: accepted, applied: accepted === true && !!result, guid: result?.guid || null };
if (params.last) {
reply.notes = ctx.app.sticky.get_content();
}
return reply;
};
if (params.type !== 'suggestion') {
const result = ctx.app.sticky.apply_edit(edit);
ctx.submit(buildReply(result, null));
return;
}
setupRequestDialog(ctx, 'Review Suggestion', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
if (edit.description) {
const descriptionEl = document.createElement('p');
descriptionEl.textContent = edit.description;
content.appendChild(descriptionEl);
}
const preview = previewText();
if (preview) {
const previewEl = document.createElement('div');
previewEl.className = 'div-edit-suggestion-preview';
previewEl.textContent = preview;
content.appendChild(previewEl);
}
const errorEl = document.createElement('span');
errorEl.className = 'span-input-request-state';
content.appendChild(errorEl);
const buttons = document.createElement('div');
buttons.className = 'div-edit-suggestion-buttons';
const acceptBtn = document.createElement('button');
acceptBtn.className = 'btn-plugin-dialog-choice';
acceptBtn.textContent = 'Accept';
acceptBtn.onclick = async () => {
setControlsBusy(content, true);
const result = ctx.app.sticky.apply_edit(edit);
if (!result) {
setControlsBusy(content, false);
errorEl.textContent = 'The referenced note no longer exists or the edit is invalid.';
return;
}
await ctx.submit(buildReply(result, true));
closeDialog();
};
const rejectBtn = document.createElement('button');
rejectBtn.className = 'btn-plugin-dialog-choice';
rejectBtn.textContent = 'Reject';
rejectBtn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit(buildReply(null, false));
closeDialog();
};
buttons.append(acceptBtn, rejectBtn);
content.appendChild(buttons);
return content;
});
}
registerInteractiveTool('edit_notes', editNotes);
/**
* edit_scratchpad handler: applies structured item edits to the scratchpad
* revision the request operates on. Non-suggested edits apply immediately and
* auto-submit; suggested edits open a confirmation dialog (title carries the
* suggestion counter) with the edit's description and the new item preview,
* and Accept/Reject buttons. The scratchpad after-state (items and table of
* contents) is submitted with the final request so the server's read tool
* stays current for the mandatory final structure check.
* @param {InteractiveToolContext} ctx - Request context.
*/
function editScratchpad(ctx) {
const params = ctx.data.params || {};
const edit = params.edit || {};
const buildReply = (applied, result, accepted) => {
const reply = accepted === null
? { applied: !!applied, guid: result?.result?.guid || null }
: { accepted: accepted, applied: accepted === true && !!applied, guid: result?.result?.guid || null };
if (params.last && result) {
reply.items = result.state.items;
reply.toc = result.state.toc;
}
return reply;
};
if (params.type !== 'suggestion') {
const result = apply_scratchpad_edit(ctx.app, ctx.guid, edit);
ctx.submit(buildReply(!!result, result, null));
return;
}
setupRequestDialog(ctx, 'Review Suggestion', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
if (edit.description) {
const descriptionEl = document.createElement('p');
descriptionEl.textContent = edit.description;
content.appendChild(descriptionEl);
}
if (edit.item && edit.item.value) {
const preview = document.createElement('div');
preview.className = 'div-edit-suggestion-preview';
preview.innerHTML = String(edit.item.value)
.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
.replace(/<\/?a\b[^>]*>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
content.appendChild(preview);
}
const errorEl = document.createElement('span');
errorEl.className = 'span-input-request-state';
content.appendChild(errorEl);
const buttons = document.createElement('div');
buttons.className = 'div-edit-suggestion-buttons';
const acceptBtn = document.createElement('button');
acceptBtn.className = 'btn-plugin-dialog-choice';
acceptBtn.textContent = 'Accept';
acceptBtn.onclick = async () => {
setControlsBusy(content, true);
const result = apply_scratchpad_edit(ctx.app, ctx.guid, edit);
if (!result) {
setControlsBusy(content, false);
errorEl.textContent = 'The referenced item no longer exists.';
return;
}
await ctx.submit(buildReply(true, result, true));
closeDialog();
};
const rejectBtn = document.createElement('button');
rejectBtn.className = 'btn-plugin-dialog-choice';
rejectBtn.textContent = 'Reject';
rejectBtn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit(buildReply(false, null, false));
closeDialog();
};
buttons.append(acceptBtn, rejectBtn);
content.appendChild(buttons);
return content;
});
}
registerInteractiveTool('edit_scratchpad', editScratchpad);
/**
* edit_scratchpad_toc handler: applies table of contents edits to the
* scratchpad revision the request operates on, following the same
* apply/suggestion flow as edit_scratchpad. The after-state is submitted
* with the final request.
* @param {InteractiveToolContext} ctx - Request context.
*/
function editScratchpadToc(ctx) {
const params = ctx.data.params || {};
const edit = params.edit || {};
const buildReply = (applied, result, accepted) => {
const reply = accepted === null
? { applied: !!applied, guid: result?.result?.guid || null }
: { accepted: accepted, applied: accepted === true && !!applied, guid: result?.result?.guid || null };
if (params.last && result) {
reply.items = result.state.items;
reply.toc = result.state.toc;
}
return reply;
};
if (params.type !== 'suggestion') {
const result = apply_scratchpad_toc_edit(ctx.app, ctx.guid, edit);
ctx.submit(buildReply(!!result, result, null));
return;
}
setupRequestDialog(ctx, 'Review Suggestion', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
if (edit.description) {
const descriptionEl = document.createElement('p');
descriptionEl.textContent = edit.description;
content.appendChild(descriptionEl);
}
if (edit.title) {
const preview = document.createElement('div');
preview.className = 'div-edit-suggestion-preview';
preview.textContent = edit.title + (edit.entry_description ? ' — ' + edit.entry_description : '');
content.appendChild(preview);
}
const errorEl = document.createElement('span');
errorEl.className = 'span-input-request-state';
content.appendChild(errorEl);
const buttons = document.createElement('div');
buttons.className = 'div-edit-suggestion-buttons';
const acceptBtn = document.createElement('button');
acceptBtn.className = 'btn-plugin-dialog-choice';
acceptBtn.textContent = 'Accept';
acceptBtn.onclick = async () => {
setControlsBusy(content, true);
const result = apply_scratchpad_toc_edit(ctx.app, ctx.guid, edit);
if (!result) {
setControlsBusy(content, false);
errorEl.textContent = 'The referenced entry no longer exists.';
return;
}
await ctx.submit(buildReply(true, result, true));
closeDialog();
};
const rejectBtn = document.createElement('button');
rejectBtn.className = 'btn-plugin-dialog-choice';
rejectBtn.textContent = 'Reject';
rejectBtn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit(buildReply(false, null, false));
closeDialog();
};
buttons.append(acceptBtn, rejectBtn);
content.appendChild(buttons);
return content;
});
}
registerInteractiveTool('edit_scratchpad_toc', editScratchpadToc);
/**
* request_disabled_tool handler: shows the requested tool and the assistant's
* reason, with three options: allow a single use this turn, block this request
* once (the assistant may ask again), or block the tool for this response.
* @param {InteractiveToolContext} ctx - Request context.
*/
function requestDisabledTool(ctx) {
const params = ctx.data.params || {};
setupRequestDialog(ctx, 'Review Request', (closeDialog) => {
const content = document.createElement('div');
content.className = 'div-plugin-dialog-body';
const promptEl = document.createElement('p');
promptEl.textContent = params.prompt || '';
content.appendChild(promptEl);
if (params.url) {
const urlEl = document.createElement('p');
urlEl.className = 'p-tool-request-reason';
urlEl.textContent = `URL: ${params.url}`;
content.appendChild(urlEl);
}
if (params.reason) {
const reasonEl = document.createElement('p');
reasonEl.className = 'p-tool-request-reason';
reasonEl.textContent = `Reason: ${params.reason}`;
content.appendChild(reasonEl);
}
if (params.imperative) {
const imperativeEl = document.createElement('p');
imperativeEl.className = 'p-tool-config-note';
imperativeEl.textContent = params.imperative;
content.appendChild(imperativeEl);
}
const options = [
['allow', 'Allow'],
['block_once', 'Block Once'],
['block_response', 'Block for This Response']
];
for (const [decision, label] of options) {
const btn = document.createElement('button');
btn.className = 'btn-plugin-dialog-choice';
btn.textContent = label;
btn.onclick = async () => {
setControlsBusy(content, true);
await ctx.submit({ decision: decision });
if (ctx.el.dataset.inputState === 'answered') {
closeDialog();
} else {
setControlsBusy(content, false);
}
};
content.appendChild(btn);
}
return content;
});
}
registerInteractiveTool('request_disabled_tool', requestDisabledTool);
/**
* limited_fetch_url handler: when the per-response fetch limit is reached, the
* assistant asks for approval to fetch one more URL. Uses the same dialog and
* options as the disabled-tool permission request.
*/
registerInteractiveTool('limited_fetch_url', requestDisabledTool);
/**
* browser-side fetch tools (AbstractBrowserFetchTool subclasses): a soft-limit
* approval request (payload type 'fetch_approval') opens the permission
* dialog; anything else is performed as a browser fetch.
*/
registerInteractiveTool('browser_fetch_wiktionary', browserFetchTool);
registerInteractiveTool('browser_fetch_wikipedia', browserFetchTool);
registerInteractiveTool('browser_fetch_noaa_forecast', browserFetchTool);
registerInteractiveTool('browser_fetch_noaa_text', browserFetchTool);
registerInteractiveTool('browser_fetch_url', browserFetchTool);
registerInteractiveTool('browser_fetch_open_meteo', browserFetchTool);
registerInteractiveTool('browser_fetch_open_meteo_air_quality', browserFetchTool);
registerInteractiveTool('browser_fetch_sunrise_sunset', browserFetchTool);
registerInteractiveTool('browser_fetch_open_library', browserFetchTool);
registerInteractiveTool('browser_fetch_code_search', browserFetchTool);
registerInteractiveTool('browser_fetch_repo_info', browserFetchTool);
registerInteractiveTool('browser_fetch_nominatim', browserFetchTool);
registerInteractiveTool('browser_fetch_overpass', browserFetchTool);
registerInteractiveTool('browser_fetch_public_holidays', browserFetchTool);
registerInteractiveTool('browser_fetch_flight_route', browserFetchTool);
registerInteractiveTool('browser_fetch_aircraft_info', browserFetchTool);
registerInteractiveTool('browser_fetch_consensus', browserFetchTool);
/** Default client-side fetch timeout for browser-side fetch tools, in milliseconds. */
const BROWSER_FETCH_TIMEOUT_MS = 20000;
/** Content length cap for browser-side fetch results, in characters. */
const BROWSER_FETCH_MAX_CONTENT = 262144;
/**
* Shared entry point for browser-side fetch tools: a soft-limit approval
* request (payload type 'fetch_approval') is rendered as a permission dialog;
* a fetch request is performed in the browser.
* @param {InteractiveToolContext} ctx - Request context.
*/
function browserFetchTool(ctx) {
if ((ctx.data.params || {}).type === 'fetch_approval') {
requestDisabledTool(ctx);
return;
}
browserFetch(ctx);
}
/**
* Shared handler for browser-side fetch tools (AbstractBrowserFetchTool): the
* server requests a URL fetch, the browser performs it and submits the raw
* response for the server to read from the database. The user is notified via
* the notification stack. Only live requests are fetched; replayed historical
* request lines (whose deadline has passed) are ignored.
* @param {InteractiveToolContext} ctx - Request context.
*/
export async function browserFetch(ctx) {
const params = ctx.data.params || {};
const url = typeof params.url === 'string' ? params.url : '';
if (!(ctx.data.expires_at > Date.now() / 1000)) {
return;
}
showNotification(typeof params.description === 'string' && params.description !== '' ? params.description : url);
// The server tool can request a longer fetch budget via timeout_ms (e.g.
// Overpass queries that run up to their 45 second server-side timeout).
const timeoutMs = (Number.isFinite(params.timeout_ms) && params.timeout_ms > 0)
? params.timeout_ms
: BROWSER_FETCH_TIMEOUT_MS;
try {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, { signal: controller.signal });
window.clearTimeout(timer);
let content = await response.text();
if (typeof params.css_selector === 'string' && params.css_selector !== '') {
content = extractSelectorContent(content, params.css_selector);
}
if (content.length > BROWSER_FETCH_MAX_CONTENT) {
content = content.substring(0, BROWSER_FETCH_MAX_CONTENT);
}
await ctx.submit({
status: response.status,
content_type: response.headers.get('content-type') || '',
content: content
});
} catch (e) {
await ctx.submit({ status: 0, content_type: '', content: '', error: e?.message || 'Fetch failed' });
}
}
/**
* Extracts the text content of the first element matching a CSS selector from
* an HTML document. Whitespace runs are collapsed so markup structure does not
* add noise to the returned text.
* @param {string} html - Raw HTML document.
* @param {string} selector - CSS selector requested by the server-side tool.
* @returns {string} The matching element's text content.
* @throws {Error} When the selector is invalid or matches no element.
*/
function extractSelectorContent(html, selector) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const el = doc.querySelector(selector);
if (!el) {
throw new Error(`CSS selector did not match any element: ${selector}`);
}
return el.textContent.replace(/\s+/g, ' ').trim();
}
interactive-tools.js
×
Type: Web, text/x-java
38.77 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/x-java
38.77 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File