import { event_types, eventSource, main_api, saveSettingsDebounced } from '../../../script.js'; import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js'; import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js'; import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js'; const MODULE_NAME = 'connection-manager'; const NONE = ''; const DEFAULT_SETTINGS = { profiles: [], selectedProfile: null, }; const CC_COMMANDS = [ 'api', 'preset', // Do not fix; CC needs to set the API twice because it could be overridden by the preset 'api', 'api-url', 'model', 'proxy', ]; const TC_COMMANDS = [ 'api', 'preset', 'api-url', 'model', 'instruct', 'context', 'instruct-state', 'tokenizer', ]; const FANCY_NAMES = { 'api': 'API', 'api-url': 'Server URL', 'preset': 'Settings Preset', 'model': 'Model', 'proxy': 'Proxy Preset', 'instruct-state': 'Instruct Mode', 'instruct': 'Instruct Template', 'context': 'Context Template', 'tokenizer': 'Tokenizer', }; /** * A wrapper for the connection manager spinner. */ class ConnectionManagerSpinner { /** * @type {AbortController[]} */ static abortControllers = []; /** @type {HTMLElement} */ spinnerElement; /** @type {AbortController} */ abortController = new AbortController(); constructor() { // @ts-ignore this.spinnerElement = document.getElementById('connection_profile_spinner'); this.abortController = new AbortController(); } start() { ConnectionManagerSpinner.abortControllers.push(this.abortController); this.spinnerElement.classList.remove('hidden'); } stop() { this.spinnerElement.classList.add('hidden'); } isAborted() { return this.abortController.signal.aborted; } static abort() { for (const controller of ConnectionManagerSpinner.abortControllers) { controller.abort(); } ConnectionManagerSpinner.abortControllers = []; } } /** * Get named arguments for the command callback. * @param {object} [args] Additional named arguments * @returns {object} Named arguments */ function getNamedArguments(args = {}) { // None of the commands here use underscored args, but better safe than sorry return { _scope: new SlashCommandScope(), _abortController: new SlashCommandAbortController(), _debugController: new SlashCommandDebugController(), _parserFlags: {}, _hasUnnamedArgument: false, quiet: 'true', ...args, }; } /** @type {() => SlashCommandEnumValue[]} */ const profilesProvider = () => [ new SlashCommandEnumValue(NONE), ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)), ]; /** * @typedef {Object} ConnectionProfile * @property {string} id Unique identifier * @property {string} mode Mode of the connection profile * @property {string} [name] Name of the connection profile * @property {string} [api] API * @property {string} [preset] Settings Preset * @property {string} [model] Model * @property {string} [proxy] Proxy Preset * @property {string} [instruct] Instruct Template * @property {string} [context] Context Template * @property {string} [instruct-state] Instruct Mode * @property {string} [tokenizer] Tokenizer */ /** * Finds the best match for the search value. * @param {string} value Search value * @returns {ConnectionProfile|null} Best match or null */ function findProfileByName(value) { // Try to find exact match const profile = extension_settings.connectionManager.profiles.find(p => p.name === value); if (profile) { return profile; } // Try to find fuzzy match const fuse = new Fuse(extension_settings.connectionManager.profiles, { keys: ['name'] }); const results = fuse.search(value); if (results.length === 0) { return null; } const bestMatch = results[0]; return bestMatch.item; } /** * Reads the connection profile from the commands. * @param {string} mode Mode of the connection profile * @param {ConnectionProfile} profile Connection profile * @param {boolean} [cleanUp] Whether to clean up the profile */ async function readProfileFromCommands(mode, profile, cleanUp = false) { const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS; const opposingCommands = mode === 'cc' ? TC_COMMANDS : CC_COMMANDS; for (const command of commands) { try { const args = getNamedArguments(); const result = await SlashCommandParser.commands[command].callback(args, ''); if (result) { profile[command] = result; continue; } } catch (error) { console.error(`Failed to execute command: ${command}`, error); } } if (cleanUp) { for (const command of opposingCommands) { if (commands.includes(command)) { continue; } delete profile[command]; } } } /** * Creates a new connection profile. * @param {string} [forceName] Name of the connection profile * @returns {Promise} Created connection profile */ async function createConnectionProfile(forceName = null) { const mode = main_api === 'openai' ? 'cc' : 'tc'; const id = uuidv4(); const profile = { id, mode, }; await readProfileFromCommands(mode, profile); const profileForDisplay = makeFancyProfile(profile); const template = await renderExtensionTemplateAsync(MODULE_NAME, 'profile', { profile: profileForDisplay }); const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n); const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken); const name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName, { rows: 2 }); if (!name) { return null; } if (isNameTaken(name) || name === NONE) { toastr.error('A profile with the same name already exists.'); return null; } profile.name = name; return profile; } /** * Deletes the selected connection profile. * @returns {Promise} */ async function deleteConnectionProfile() { const selectedProfile = extension_settings.connectionManager.selectedProfile; if (!selectedProfile) { return; } const index = extension_settings.connectionManager.profiles.findIndex(p => p.id === selectedProfile); if (index === -1) { return; } const name = extension_settings.connectionManager.profiles[index].name; const confirm = await Popup.show.confirm('Are you sure you want to delete the selected profile?', name); if (!confirm) { return; } extension_settings.connectionManager.profiles.splice(index, 1); extension_settings.connectionManager.selectedProfile = null; saveSettingsDebounced(); } /** * Formats the connection profile for display. * @param {ConnectionProfile} profile Connection profile * @returns {Object} Fancy profile */ function makeFancyProfile(profile) { return Object.entries(FANCY_NAMES).reduce((acc, [key, value]) => { if (!profile[key]) return acc; acc[value] = profile[key]; return acc; }, {}); } /** * Applies the connection profile. * @param {ConnectionProfile} profile Connection profile * @returns {Promise} */ async function applyConnectionProfile(profile) { if (!profile) { return; } // Abort any ongoing profile application ConnectionManagerSpinner.abort(); const mode = profile.mode; const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS; const spinner = new ConnectionManagerSpinner(); spinner.start(); for (const command of commands) { if (spinner.isAborted()) { throw new Error('Profile application aborted'); } const argument = profile[command]; if (!argument) { continue; } try { const args = getNamedArguments(); await SlashCommandParser.commands[command].callback(args, argument); } catch (error) { console.error(`Failed to execute command: ${command} ${argument}`, error); } } spinner.stop(); } /** * Updates the selected connection profile. * @param {ConnectionProfile} profile Connection profile * @returns {Promise} */ async function updateConnectionProfile(profile) { profile.mode = main_api === 'openai' ? 'cc' : 'tc'; await readProfileFromCommands(profile.mode, profile, true); } /** * Renders the connection profile details. * @param {HTMLSelectElement} profiles Select element containing connection profiles */ function renderConnectionProfiles(profiles) { profiles.innerHTML = ''; const noneOption = document.createElement('option'); noneOption.value = ''; noneOption.textContent = NONE; noneOption.selected = !extension_settings.connectionManager.selectedProfile; profiles.appendChild(noneOption); for (const profile of extension_settings.connectionManager.profiles) { const option = document.createElement('option'); option.value = profile.id; option.textContent = profile.name; option.selected = profile.id === extension_settings.connectionManager.selectedProfile; profiles.appendChild(option); } } /** * Renders the content of the details element. * @param {HTMLElement} detailsContent Content element of the details */ async function renderDetailsContent(detailsContent) { detailsContent.innerHTML = ''; if (detailsContent.classList.contains('hidden')) { return; } const selectedProfile = extension_settings.connectionManager.selectedProfile; const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); if (profile) { const profileForDisplay = makeFancyProfile(profile); const template = await renderExtensionTemplateAsync(MODULE_NAME, 'view', { profile: profileForDisplay }); detailsContent.innerHTML = template; } else { detailsContent.textContent = 'No profile selected'; } } (async function () { extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS); for (const key of Object.keys(DEFAULT_SETTINGS)) { if (extension_settings.connectionManager[key] === undefined) { extension_settings.connectionManager[key] = DEFAULT_SETTINGS[key]; } } const container = document.getElementById('rm_api_block'); const settings = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); container.insertAdjacentHTML('afterbegin', settings); /** @type {HTMLSelectElement} */ // @ts-ignore const profiles = document.getElementById('connection_profiles'); renderConnectionProfiles(profiles); function toggleProfileSpecificButtons() { const profileId = extension_settings.connectionManager.selectedProfile; const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile']; profileSpecificButtons.forEach(id => document.getElementById(id).classList.toggle('disabled', !profileId)); } toggleProfileSpecificButtons(); profiles.addEventListener('change', async function () { const selectedProfile = profiles.selectedOptions[0]; if (!selectedProfile) { // Safety net for preventing the command getting stuck await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE); return; } const profileId = selectedProfile.value; extension_settings.connectionManager.selectedProfile = profileId; saveSettingsDebounced(); await renderDetailsContent(detailsContent); toggleProfileSpecificButtons(); // None option selected if (!profileId) { await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE); return; } const profile = extension_settings.connectionManager.profiles.find(p => p.id === profileId); if (!profile) { console.log(`Profile not found: ${profileId}`); return; } await applyConnectionProfile(profile); await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name); }); const reloadButton = document.getElementById('reload_connection_profile'); reloadButton.addEventListener('click', async () => { const selectedProfile = extension_settings.connectionManager.selectedProfile; const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); if (!profile) { console.log('No profile selected'); return; } await applyConnectionProfile(profile); await renderDetailsContent(detailsContent); await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name); toastr.success('Connection profile reloaded', '', { timeOut: 1500 }); }); const createButton = document.getElementById('create_connection_profile'); createButton.addEventListener('click', async () => { const profile = await createConnectionProfile(); if (!profile) { return; } extension_settings.connectionManager.profiles.push(profile); extension_settings.connectionManager.selectedProfile = profile.id; saveSettingsDebounced(); renderConnectionProfiles(profiles); await renderDetailsContent(detailsContent); await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name); }); const updateButton = document.getElementById('update_connection_profile'); updateButton.addEventListener('click', async () => { const selectedProfile = extension_settings.connectionManager.selectedProfile; const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); if (!profile) { console.log('No profile selected'); return; } await updateConnectionProfile(profile); await renderDetailsContent(detailsContent); saveSettingsDebounced(); await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name); toastr.success('Connection profile updated', '', { timeOut: 1500 }); }); const deleteButton = document.getElementById('delete_connection_profile'); deleteButton.addEventListener('click', async () => { await deleteConnectionProfile(); renderConnectionProfiles(profiles); await renderDetailsContent(detailsContent); await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE); }); /** @type {HTMLElement} */ const viewDetails = document.getElementById('view_connection_profile'); const detailsContent = document.getElementById('connection_profile_details_content'); viewDetails.addEventListener('click', async () => { viewDetails.classList.toggle('active'); detailsContent.classList.toggle('hidden'); await renderDetailsContent(detailsContent); }); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'profile', helpString: 'Switch to a connection profile or return the name of the current profile in no argument is provided. Use <None> to switch to no profile.', returns: 'name of the profile', unnamedArgumentList: [ SlashCommandArgument.fromProps({ description: 'Name of the connection profile', enumProvider: profilesProvider, isRequired: false, }), ], namedArgumentList: [ SlashCommandNamedArgument.fromProps({ name: 'await', description: 'Wait for the connection profile to be applied before returning.', isRequired: false, typeList: [ARGUMENT_TYPE.BOOLEAN], defaultValue: 'true', enumList: commonEnumProviders.boolean('trueFalse')(), }), ], callback: async (args, value) => { if (!value || typeof value !== 'string') { const selectedProfile = extension_settings.connectionManager.selectedProfile; const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); if (!profile) { return NONE; } return profile.name; } if (value === NONE) { profiles.selectedIndex = 0; profiles.dispatchEvent(new Event('change')); return NONE; } const profile = findProfileByName(value); if (!profile) { return ''; } const shouldAwait = !isFalseBoolean(String(args?.await)); const awaitPromise = new Promise((resolve) => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve)); profiles.selectedIndex = Array.from(profiles.options).findIndex(o => o.value === profile.id); profiles.dispatchEvent(new Event('change')); if (shouldAwait) { await awaitPromise; } return profile.name; }, })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'profile-list', helpString: 'List all connection profile names.', returns: 'list of profile names', callback: () => JSON.stringify(extension_settings.connectionManager.profiles.map(p => p.name)), })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'profile-create', returns: 'name of the new profile', helpString: 'Create a new connection profile using the current settings.', unnamedArgumentList: [ SlashCommandArgument.fromProps({ description: 'name of the new connection profile', isRequired: true, typeList: [ARGUMENT_TYPE.STRING], }), ], callback: async (_args, name) => { if (!name || typeof name !== 'string') { toastr.warning('Please provide a name for the new connection profile.'); return ''; } const profile = await createConnectionProfile(name); if (!profile) { return ''; } extension_settings.connectionManager.profiles.push(profile); extension_settings.connectionManager.selectedProfile = profile.id; saveSettingsDebounced(); renderConnectionProfiles(profiles); await renderDetailsContent(detailsContent); return profile.name; }, })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'profile-update', helpString: 'Update the selected connection profile.', callback: async () => { const selectedProfile = extension_settings.connectionManager.selectedProfile; const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); if (!profile) { toastr.warning('No profile selected.'); return ''; } await updateConnectionProfile(profile); await renderDetailsContent(detailsContent); saveSettingsDebounced(); return profile.name; }, })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'profile-get', helpString: 'Get the details of the connection profile. Returns the selected profile if no argument is provided.', returns: 'object of the selected profile', unnamedArgumentList: [ SlashCommandArgument.fromProps({ description: 'Name of the connection profile', enumProvider: profilesProvider, isRequired: false, }), ], callback: async (_args, value) => { if (!value || typeof value !== 'string') { const selectedProfile = extension_settings.connectionManager.selectedProfile; const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); if (!profile) { return ''; } return JSON.stringify(profile); } const profile = findProfileByName(value); if (!profile) { return ''; } return JSON.stringify(profile); }, })); })();