0 directories, 46 files

util

Home / tinai / util
/**
 * 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