| 1 | +import { main_api, saveSettingsDebounced } from '../../../script.js'; |
| 2 | +import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 3 | +import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 4 | +import { executeSlashCommandsWithOptions } from '../../slash-commands.js'; |
| 5 | + |
| 6 | +const MODULE_NAME = 'connection-manager'; |
| 7 | + |
| 8 | +const DEFAULT_SETTINGS = { |
| 9 | + profiles: [], |
| 10 | + selectedProfile: null, |
| 11 | +}; |
| 12 | + |
| 13 | +const COMMON_COMMANDS = [ |
| 14 | + 'api', |
| 15 | + 'preset', |
| 16 | + 'model', |
| 17 | +]; |
| 18 | + |
| 19 | +const CC_COMMANDS = [ |
| 20 | + ...COMMON_COMMANDS, |
| 21 | + 'proxy', |
| 22 | +]; |
| 23 | + |
| 24 | +const TC_COMMANDS = [ |
| 25 | + ...COMMON_COMMANDS, |
| 26 | + 'instruct', |
| 27 | + 'context', |
| 28 | + 'instruct-state', |
| 29 | + 'tokenizer', |
| 30 | +]; |
| 31 | + |
| 32 | +const FANCY_NAMES = { |
| 33 | + 'api': 'API', |
| 34 | + 'preset': 'Settings Preset', |
| 35 | + 'model': 'Model', |
| 36 | + 'proxy': 'Proxy Preset', |
| 37 | + 'instruct-state': 'Instruct Mode', |
| 38 | + 'instruct': 'Instruct Template', |
| 39 | + 'context': 'Context Template', |
| 40 | + 'tokenizer': 'Tokenizer', |
| 41 | +}; |
| 42 | + |
| 43 | +/** |
| 44 | + * @typedef {Object} ConnectionProfile |
| 45 | + * @property {string} id Unique identifier |
| 46 | + * @property {string} mode Mode of the connection profile |
| 47 | + * @property {string} [name] Name of the connection profile |
| 48 | + * @property {string} [api] API |
| 49 | + * @property {string} [preset] Settings Preset |
| 50 | + * @property {string} [model] Model |
| 51 | + * @property {string} [proxy] Proxy Preset |
| 52 | + * @property {string} [instruct] Instruct Template |
| 53 | + * @property {string} [context] Context Template |
| 54 | + * @property {string} [instruct-state] Instruct Mode |
| 55 | + * @property {string} [tokenizer] Tokenizer |
| 56 | + */ |
| 57 | + |
| 58 | +const escapeArgument = (a) => a.replace(/"/g, '\\"').replace(/\|/g, '\\|'); |
| 59 | + |
| 60 | +/** |
| 61 | + * Reads the connection profile from the commands. |
| 62 | + * @param {string} mode Mode of the connection profile |
| 63 | + * @param {ConnectionProfile} profile Connection profile |
| 64 | + * @param {boolean} [cleanUp] Whether to clean up the profile |
| 65 | + */ |
| 66 | +async function readProfileFromCommands(mode, profile, cleanUp = false) { |
| 67 | + const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS; |
| 68 | + const opposingCommands = mode === 'cc' ? TC_COMMANDS : CC_COMMANDS; |
| 69 | + for (const command of commands) { |
| 70 | + const commandText = `/${command} quiet=true`; |
| 71 | + try { |
| 72 | + const result = await executeSlashCommandsWithOptions(commandText, { handleParserErrors: false, handleExecutionErrors: false }); |
| 73 | + if (result.pipe) { |
| 74 | + profile[command] = result.pipe; |
| 75 | + continue; |
| 76 | + } |
| 77 | + } catch (error) { |
| 78 | + console.warn(`Failed to execute command: ${commandText}`, error); |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + if (cleanUp) { |
| 83 | + for (const command of opposingCommands) { |
| 84 | + if (commands.includes(command)) { |
| 85 | + continue; |
| 86 | + } |
| 87 | + |
| 88 | + delete profile[command]; |
| 89 | + } |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +/** |
| 94 | + * Creates a new connection profile. |
| 95 | + * @returns {Promise<ConnectionProfile>} Created connection profile |
| 96 | + */ |
| 97 | +async function createConnectionProfile() { |
| 98 | + const mode = main_api === 'openai' ? 'cc' : 'tc'; |
| 99 | + const id = 'profile-' + Math.random().toString(36).substring(2); |
| 100 | + const profile = { |
| 101 | + id, |
| 102 | + mode, |
| 103 | + }; |
| 104 | + |
| 105 | + await readProfileFromCommands(mode, profile); |
| 106 | + |
| 107 | + const profileForDisplay = makeFancyProfile(profile); |
| 108 | + const template = await renderExtensionTemplateAsync(MODULE_NAME, 'profile', { profile: profileForDisplay }); |
| 109 | + const suggestedName = `${profile.api} ${profile.model} - ${profile.preset}`; |
| 110 | + const name = await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName, { rows: 2 }); |
| 111 | + |
| 112 | + if (!name) { |
| 113 | + return; |
| 114 | + } |
| 115 | + |
| 116 | + profile.name = name; |
| 117 | + return profile; |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * Deletes the selected connection profile. |
| 122 | + * @returns {Promise<void>} |
| 123 | + */ |
| 124 | +async function deleteConnectionProfile() { |
| 125 | + const selectedProfile = extension_settings.connectionManager.selectedProfile; |
| 126 | + if (!selectedProfile) { |
| 127 | + return; |
| 128 | + } |
| 129 | + |
| 130 | + const index = extension_settings.connectionManager.profiles.findIndex(p => p.id === selectedProfile); |
| 131 | + if (index === -1) { |
| 132 | + return; |
| 133 | + } |
| 134 | + |
| 135 | + const confirm = await Popup.show.confirm('Are you sure you want to delete the selected profile?', null); |
| 136 | + |
| 137 | + if (!confirm) { |
| 138 | + return; |
| 139 | + } |
| 140 | + |
| 141 | + extension_settings.connectionManager.profiles.splice(index, 1); |
| 142 | + extension_settings.connectionManager.selectedProfile = null; |
| 143 | + saveSettingsDebounced(); |
| 144 | +} |
| 145 | + |
| 146 | +/** |
| 147 | + * Formats the connection profile for display. |
| 148 | + * @param {ConnectionProfile} profile Connection profile |
| 149 | + * @returns {Object} Fancy profile |
| 150 | + */ |
| 151 | +function makeFancyProfile(profile) { |
| 152 | + return Object.entries(FANCY_NAMES).reduce((acc, [key, value]) => { |
| 153 | + if (!profile[key]) return acc; |
| 154 | + acc[value] = profile[key]; |
| 155 | + return acc; |
| 156 | + }, {}); |
| 157 | +} |
| 158 | + |
| 159 | +/** |
| 160 | + * Applies the connection profile. |
| 161 | + * @param {ConnectionProfile} profile Connection profile |
| 162 | + * @returns {Promise<void>} |
| 163 | + */ |
| 164 | +async function applyConnectionProfile(profile) { |
| 165 | + if (!profile) { |
| 166 | + return; |
| 167 | + } |
| 168 | + |
| 169 | + const mode = profile.mode; |
| 170 | + const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS; |
| 171 | + |
| 172 | + for (const command of commands) { |
| 173 | + const argument = profile[command]; |
| 174 | + if (!argument) { |
| 175 | + continue; |
| 176 | + } |
| 177 | + const commandText = `/${command} quiet=true ${escapeArgument(argument)}`; |
| 178 | + try { |
| 179 | + await executeSlashCommandsWithOptions(commandText, { handleParserErrors: false, handleExecutionErrors: false }); |
| 180 | + } catch (error) { |
| 181 | + console.warn(`Failed to execute command: ${commandText}`, error); |
| 182 | + } |
| 183 | + } |
| 184 | +} |
| 185 | + |
| 186 | +/** |
| 187 | + * Updates the selected connection profile. |
| 188 | + * @returns {Promise<void>} |
| 189 | + */ |
| 190 | +async function updateConnectionProfile() { |
| 191 | + const selectedProfile = extension_settings.connectionManager.selectedProfile; |
| 192 | + const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); |
| 193 | + if (!profile) { |
| 194 | + console.log('No profile selected'); |
| 195 | + return; |
| 196 | + } |
| 197 | + |
| 198 | + profile.mode = main_api === 'openai' ? 'cc' : 'tc'; |
| 199 | + await readProfileFromCommands(profile.mode, profile, true); |
| 200 | +} |
| 201 | + |
| 202 | +/** |
| 203 | + * Renders the connection profile details. |
| 204 | + * @param {HTMLSelectElement} profiles Select element containing connection profiles |
| 205 | + */ |
| 206 | +function renderConnectionProfiles(profiles) { |
| 207 | + profiles.innerHTML = ''; |
| 208 | + const noneOption = document.createElement('option'); |
| 209 | + |
| 210 | + noneOption.value = ''; |
| 211 | + noneOption.textContent = '<None>'; |
| 212 | + noneOption.selected = !extension_settings.connectionManager.selectedProfile; |
| 213 | + profiles.appendChild(noneOption); |
| 214 | + |
| 215 | + for (const profile of extension_settings.connectionManager.profiles) { |
| 216 | + const option = document.createElement('option'); |
| 217 | + option.value = profile.id; |
| 218 | + option.textContent = profile.name; |
| 219 | + option.selected = profile.id === extension_settings.connectionManager.selectedProfile; |
| 220 | + profiles.appendChild(option); |
| 221 | + } |
| 222 | +} |
| 223 | + |
| 224 | +/** |
| 225 | + * Renders the content of the details element. |
| 226 | + * @param {HTMLDetailsElement} details Details element |
| 227 | + * @param {HTMLElement} detailsContent Content element of the details |
| 228 | + */ |
| 229 | +async function renderDetailsContent(details, detailsContent) { |
| 230 | + detailsContent.innerHTML = ''; |
| 231 | + if (details.open) { |
| 232 | + const selectedProfile = extension_settings.connectionManager.selectedProfile; |
| 233 | + const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); |
| 234 | + if (profile) { |
| 235 | + const profileForDisplay = makeFancyProfile(profile); |
| 236 | + const template = await renderExtensionTemplateAsync(MODULE_NAME, 'view', { profile: profileForDisplay }); |
| 237 | + detailsContent.innerHTML = template; |
| 238 | + } else { |
| 239 | + detailsContent.textContent = 'No profile selected'; |
| 240 | + } |
| 241 | + } |
| 242 | +} |
| 243 | + |
| 244 | +(async function () { |
| 245 | + extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS); |
| 246 | + |
| 247 | + for (const key of Object.keys(DEFAULT_SETTINGS)) { |
| 248 | + if (extension_settings.connectionManager[key] === undefined) { |
| 249 | + extension_settings.connectionManager[key] = DEFAULT_SETTINGS[key]; |
| 250 | + } |
| 251 | + } |
| 252 | + |
| 253 | + const container = document.getElementById('rm_api_block'); |
| 254 | + const settings = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); |
| 255 | + container.insertAdjacentHTML('afterbegin', settings); |
| 256 | + |
| 257 | + /** @type {HTMLSelectElement} */ |
| 258 | + // @ts-ignore |
| 259 | + const profiles = document.getElementById('connection_profiles'); |
| 260 | + renderConnectionProfiles(profiles); |
| 261 | + |
| 262 | + profiles.addEventListener('change', async function () { |
| 263 | + const selectedProfile = profiles.selectedOptions[0]; |
| 264 | + if (!selectedProfile) { |
| 265 | + return; |
| 266 | + } |
| 267 | + |
| 268 | + const profileId = selectedProfile.value; |
| 269 | + extension_settings.connectionManager.selectedProfile = profileId; |
| 270 | + saveSettingsDebounced(); |
| 271 | + await renderDetailsContent(details, detailsContent); |
| 272 | + |
| 273 | + const profile = extension_settings.connectionManager.profiles.find(p => p.id === profileId); |
| 274 | + |
| 275 | + if (!profile) { |
| 276 | + console.log(`Profile not found: ${profileId}`); |
| 277 | + return; |
| 278 | + } |
| 279 | + |
| 280 | + await applyConnectionProfile(profile); |
| 281 | + }); |
| 282 | + |
| 283 | + const reloadButton = document.getElementById('reload_connection_profile'); |
| 284 | + reloadButton.addEventListener('click', async () => { |
| 285 | + const selectedProfile = extension_settings.connectionManager.selectedProfile; |
| 286 | + const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile); |
| 287 | + if (!profile) { |
| 288 | + console.log('No profile selected'); |
| 289 | + return; |
| 290 | + } |
| 291 | + await applyConnectionProfile(profile); |
| 292 | + await renderDetailsContent(details, detailsContent); |
| 293 | + toastr.success('Connection profile reloaded', '', { timeOut: 1500 }); |
| 294 | + }); |
| 295 | + |
| 296 | + const createButton = document.getElementById('create_connection_profile'); |
| 297 | + createButton.addEventListener('click', async () => { |
| 298 | + const profile = await createConnectionProfile(); |
| 299 | + extension_settings.connectionManager.profiles.push(profile); |
| 300 | + extension_settings.connectionManager.selectedProfile = profile.id; |
| 301 | + saveSettingsDebounced(); |
| 302 | + renderConnectionProfiles(profiles); |
| 303 | + await renderDetailsContent(details, detailsContent); |
| 304 | + }); |
| 305 | + |
| 306 | + const updateButton = document.getElementById('update_connection_profile'); |
| 307 | + updateButton.addEventListener('click', async () => { |
| 308 | + await updateConnectionProfile(); |
| 309 | + await renderDetailsContent(details, detailsContent); |
| 310 | + saveSettingsDebounced(); |
| 311 | + toastr.success('Connection profile updated', '', { timeOut: 1500 }); |
| 312 | + }); |
| 313 | + |
| 314 | + const deleteButton = document.getElementById('delete_connection_profile'); |
| 315 | + deleteButton.addEventListener('click', async () => { |
| 316 | + await deleteConnectionProfile(); |
| 317 | + renderConnectionProfiles(profiles); |
| 318 | + await renderDetailsContent(details, detailsContent); |
| 319 | + }); |
| 320 | + |
| 321 | + /** @type {HTMLDetailsElement} */ |
| 322 | + // @ts-ignore |
| 323 | + const details = document.getElementById('connection_profile_details'); |
| 324 | + const detailsContent = document.getElementById('connection_profile_details_content'); |
| 325 | + details.addEventListener('toggle', () => renderDetailsContent(details, detailsContent)); |
| 326 | +})(); |