Merge pull request #2797 from SillyTavern/connection-manager Implement Connection Manager

d9ea3d48dd00ab1ac9aba3e72cefe6f3a548f9f6

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
13 files changed, +704 -17Showing whitespace changes
public/script.js+13 -6
@@ -242,6 +242,7 @@ import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js'
242import { initDynamicStyles } from './scripts/dynamic-styles.js';242import { initDynamicStyles } from './scripts/dynamic-styles.js';
243import { SlashCommandEnumValue, enumTypes } from './scripts/slash-commands/SlashCommandEnumValue.js';243import { SlashCommandEnumValue, enumTypes } from './scripts/slash-commands/SlashCommandEnumValue.js';
244import { commonEnumProviders, enumIcons } from './scripts/slash-commands/SlashCommandCommonEnumsProvider.js';244import { commonEnumProviders, enumIcons } from './scripts/slash-commands/SlashCommandCommonEnumsProvider.js';
245import { AbortReason } from './scripts/util/AbortReason.js';
245246
246//exporting functions and vars for mods247//exporting functions and vars for mods
247export {248export {
@@ -462,6 +463,7 @@ export const event_types = {
462 LLM_FUNCTION_TOOL_CALL: 'llm_function_tool_call',463 LLM_FUNCTION_TOOL_CALL: 'llm_function_tool_call',
463 ONLINE_STATUS_CHANGED: 'online_status_changed',464 ONLINE_STATUS_CHANGED: 'online_status_changed',
464 IMAGE_SWIPED: 'image_swiped',465 IMAGE_SWIPED: 'image_swiped',
466 CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
465};467};
466468
467export const eventSource = new EventEmitter();469export const eventSource = new EventEmitter();
@@ -977,8 +979,8 @@ async function fixViewport() {
977 document.body.style.position = '';979 document.body.style.position = '';
978}980}
979981
980function cancelStatusCheck() {982function cancelStatusCheck(reason = 'Manually cancelled status check') {
981 abortStatusCheck?.abort();983 abortStatusCheck?.abort(new AbortReason(reason));
982 abortStatusCheck = new AbortController();984 abortStatusCheck = new AbortController();
983 setOnlineStatus('no_connection');985 setOnlineStatus('no_connection');
984}986}
@@ -1228,7 +1230,12 @@ async function getStatusTextgen() {
1228 toastr.error(data.response, 'API Error', { timeOut: 5000, preventDuplicates: true });1230 toastr.error(data.response, 'API Error', { timeOut: 5000, preventDuplicates: true });
1229 }1231 }
1230 } catch (err) {1232 } catch (err) {
1233 if (err instanceof AbortReason) {
1234 console.info('Status check aborted.', err.reason);
1235 } else {
1231 console.error('Error getting status', err);1236 console.error('Error getting status', err);
1237
1238 }
1232 setOnlineStatus('no_connection');1239 setOnlineStatus('no_connection');
1233 }1240 }
12341241
@@ -8519,7 +8526,7 @@ async function selectContextCallback(args, name) {
8519 }8526 }
85208527
8521 const foundName = result[0].item;8528 const foundName = result[0].item;
8522 selectContextPreset(foundName, quiet);8529 selectContextPreset(foundName, { quiet: quiet });
8523 return foundName;8530 return foundName;
8524}8531}
85258532
@@ -8539,7 +8546,7 @@ async function selectInstructCallback(args, name) {
8539 }8546 }
85408547
8541 const foundName = result[0].item;8548 const foundName = result[0].item;
8542 selectInstructPreset(foundName, quiet);8549 selectInstructPreset(foundName, { quiet: quiet });
8543 return foundName;8550 return foundName;
8544}8551}
85458552
@@ -9316,7 +9323,7 @@ jQuery(async function () {
9316 $('#groupCurrentMemberListToggle .inline-drawer-icon').trigger('click');9323 $('#groupCurrentMemberListToggle .inline-drawer-icon').trigger('click');
9317 }, 200);9324 }, 200);
93189325
9319 $(document).on('click', '.api_loading', cancelStatusCheck);9326 $(document).on('click', '.api_loading', () => cancelStatusCheck('Canceled because connecting was manually canceled'));
93209327
9321 //////////INPUT BAR FOCUS-KEEPING LOGIC/////////////9328 //////////INPUT BAR FOCUS-KEEPING LOGIC/////////////
9322 let S_TAPreviouslyFocused = false;9329 let S_TAPreviouslyFocused = false;
@@ -10075,7 +10082,7 @@ jQuery(async function () {
10075 });10082 });
1007610083
10077 $('#main_api').change(function () {10084 $('#main_api').change(function () {
10078 cancelStatusCheck();10085 cancelStatusCheck('Canceled because main api changed');
10079 changeMainAPI();10086 changeMainAPI();
10080 saveSettingsDebounced();10087 saveSettingsDebounced();
10081 });10088 });
public/scripts/extensions.js+5 -0
@@ -123,6 +123,11 @@ const extension_settings = {
123 /** @type {string[]} */123 /** @type {string[]} */
124 custom: [],124 custom: [],
125 },125 },
126 connectionManager: {
127 selectedProfile: '',
128 /** @type {import('./extensions/connection-manager/index.js').ConnectionProfile[]} */
129 profiles: [],
130 },
126 dice: {},131 dice: {},
127 /** @type {import('./char-data.js').RegexScriptData[]} */132 /** @type {import('./char-data.js').RegexScriptData[]} */
128 regex: [],133 regex: [],
public/scripts/extensions/connection-manager/index.js+594 -0
@@ -0,0 +1,594 @@
1import { event_types, eventSource, main_api, saveSettingsDebounced } from '../../../script.js';
2import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
3import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
4import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
5import { SlashCommand } from '../../slash-commands/SlashCommand.js';
6import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
7import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
8import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
9import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
10import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js';
11
12const MODULE_NAME = 'connection-manager';
13const NONE = '<None>';
14
15const DEFAULT_SETTINGS = {
16 profiles: [],
17 selectedProfile: null,
18};
19
20const COMMON_COMMANDS = [
21 'api',
22 'preset',
23 'api-url',
24 'model',
25];
26
27const CC_COMMANDS = [
28 ...COMMON_COMMANDS,
29 'proxy',
30];
31
32const TC_COMMANDS = [
33 ...COMMON_COMMANDS,
34 'instruct',
35 'context',
36 'instruct-state',
37 'tokenizer',
38];
39
40const FANCY_NAMES = {
41 'api': 'API',
42 'api-url': 'Server URL',
43 'preset': 'Settings Preset',
44 'model': 'Model',
45 'proxy': 'Proxy Preset',
46 'instruct-state': 'Instruct Mode',
47 'instruct': 'Instruct Template',
48 'context': 'Context Template',
49 'tokenizer': 'Tokenizer',
50};
51
52/**
53 * A wrapper for the connection manager spinner.
54 */
55class ConnectionManagerSpinner {
56 /**
57 * @type {AbortController[]}
58 */
59 static abortControllers = [];
60
61 /** @type {HTMLElement} */
62 spinnerElement;
63
64 /** @type {AbortController} */
65 abortController = new AbortController();
66
67 constructor() {
68 // @ts-ignore
69 this.spinnerElement = document.getElementById('connection_profile_spinner');
70 this.abortController = new AbortController();
71 }
72
73 start() {
74 ConnectionManagerSpinner.abortControllers.push(this.abortController);
75 this.spinnerElement.classList.remove('hidden');
76 }
77
78 stop() {
79 this.spinnerElement.classList.add('hidden');
80 }
81
82 isAborted() {
83 return this.abortController.signal.aborted;
84 }
85
86 static abort() {
87 for (const controller of ConnectionManagerSpinner.abortControllers) {
88 controller.abort();
89 }
90 ConnectionManagerSpinner.abortControllers = [];
91 }
92}
93
94/** @type {() => SlashCommandEnumValue[]} */
95const profilesProvider = () => [
96 new SlashCommandEnumValue(NONE),
97 ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
98];
99
100/**
101 * @typedef {Object} ConnectionProfile
102 * @property {string} id Unique identifier
103 * @property {string} mode Mode of the connection profile
104 * @property {string} [name] Name of the connection profile
105 * @property {string} [api] API
106 * @property {string} [preset] Settings Preset
107 * @property {string} [model] Model
108 * @property {string} [proxy] Proxy Preset
109 * @property {string} [instruct] Instruct Template
110 * @property {string} [context] Context Template
111 * @property {string} [instruct-state] Instruct Mode
112 * @property {string} [tokenizer] Tokenizer
113 */
114
115const escapeArgument = (a) => a.replace(/"/g, '\\"').replace(/\|/g, '\\|');
116
117/**
118 * Finds the best match for the search value.
119 * @param {string} value Search value
120 * @returns {ConnectionProfile|null} Best match or null
121 */
122function findProfileByName(value) {
123 // Try to find exact match
124 const profile = extension_settings.connectionManager.profiles.find(p => p.name === value);
125
126 if (profile) {
127 return profile;
128 }
129
130 // Try to find fuzzy match
131 const fuse = new Fuse(extension_settings.connectionManager.profiles, { keys: ['name'] });
132 const results = fuse.search(value);
133
134 if (results.length === 0) {
135 return null;
136 }
137
138 const bestMatch = results[0];
139 return bestMatch.item;
140}
141
142/**
143 * Reads the connection profile from the commands.
144 * @param {string} mode Mode of the connection profile
145 * @param {ConnectionProfile} profile Connection profile
146 * @param {boolean} [cleanUp] Whether to clean up the profile
147 */
148async function readProfileFromCommands(mode, profile, cleanUp = false) {
149 const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
150 const opposingCommands = mode === 'cc' ? TC_COMMANDS : CC_COMMANDS;
151 for (const command of commands) {
152 const commandText = `/${command} quiet=true`;
153 try {
154 const result = await executeSlashCommandsWithOptions(commandText, { handleParserErrors: false, handleExecutionErrors: false });
155 if (result.pipe) {
156 profile[command] = result.pipe;
157 continue;
158 }
159 } catch (error) {
160 console.warn(`Failed to execute command: ${commandText}`, error);
161 }
162 }
163
164 if (cleanUp) {
165 for (const command of opposingCommands) {
166 if (commands.includes(command)) {
167 continue;
168 }
169
170 delete profile[command];
171 }
172 }
173}
174
175/**
176 * Creates a new connection profile.
177 * @param {string} [forceName] Name of the connection profile
178 * @returns {Promise<ConnectionProfile>} Created connection profile
179 */
180async function createConnectionProfile(forceName = null) {
181 const mode = main_api === 'openai' ? 'cc' : 'tc';
182 const id = uuidv4();
183 const profile = {
184 id,
185 mode,
186 };
187
188 await readProfileFromCommands(mode, profile);
189
190 const profileForDisplay = makeFancyProfile(profile);
191 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'profile', { profile: profileForDisplay });
192 const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n);
193 const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken);
194 const name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName, { rows: 2 });
195
196 if (!name) {
197 return null;
198 }
199
200 if (isNameTaken(name) || name === NONE) {
201 toastr.error('A profile with the same name already exists.');
202 return null;
203 }
204
205 profile.name = name;
206 return profile;
207}
208
209/**
210 * Deletes the selected connection profile.
211 * @returns {Promise<void>}
212 */
213async function deleteConnectionProfile() {
214 const selectedProfile = extension_settings.connectionManager.selectedProfile;
215 if (!selectedProfile) {
216 return;
217 }
218
219 const index = extension_settings.connectionManager.profiles.findIndex(p => p.id === selectedProfile);
220 if (index === -1) {
221 return;
222 }
223
224 const name = extension_settings.connectionManager.profiles[index].name;
225 const confirm = await Popup.show.confirm('Are you sure you want to delete the selected profile?', name);
226
227 if (!confirm) {
228 return;
229 }
230
231 extension_settings.connectionManager.profiles.splice(index, 1);
232 extension_settings.connectionManager.selectedProfile = null;
233 saveSettingsDebounced();
234}
235
236/**
237 * Formats the connection profile for display.
238 * @param {ConnectionProfile} profile Connection profile
239 * @returns {Object} Fancy profile
240 */
241function makeFancyProfile(profile) {
242 return Object.entries(FANCY_NAMES).reduce((acc, [key, value]) => {
243 if (!profile[key]) return acc;
244 acc[value] = profile[key];
245 return acc;
246 }, {});
247}
248
249/**
250 * Applies the connection profile.
251 * @param {ConnectionProfile} profile Connection profile
252 * @returns {Promise<void>}
253 */
254async function applyConnectionProfile(profile) {
255 if (!profile) {
256 return;
257 }
258
259 // Abort any ongoing profile application
260 ConnectionManagerSpinner.abort();
261
262 const mode = profile.mode;
263 const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
264 const spinner = new ConnectionManagerSpinner();
265 spinner.start();
266
267 for (const command of commands) {
268 if (spinner.isAborted()) {
269 throw new Error('Profile application aborted');
270 }
271
272 const argument = profile[command];
273 if (!argument) {
274 continue;
275 }
276 const commandText = `/${command} quiet=true ${escapeArgument(argument)}`;
277 try {
278 await executeSlashCommandsWithOptions(commandText, { handleParserErrors: false, handleExecutionErrors: false });
279 } catch (error) {
280 console.error(`Failed to execute command: ${commandText}`, error);
281 }
282 }
283
284 spinner.stop();
285}
286
287/**
288 * Updates the selected connection profile.
289 * @param {ConnectionProfile} profile Connection profile
290 * @returns {Promise<void>}
291 */
292async function updateConnectionProfile(profile) {
293 profile.mode = main_api === 'openai' ? 'cc' : 'tc';
294 await readProfileFromCommands(profile.mode, profile, true);
295}
296
297/**
298 * Renders the connection profile details.
299 * @param {HTMLSelectElement} profiles Select element containing connection profiles
300 */
301function renderConnectionProfiles(profiles) {
302 profiles.innerHTML = '';
303 const noneOption = document.createElement('option');
304
305 noneOption.value = '';
306 noneOption.textContent = NONE;
307 noneOption.selected = !extension_settings.connectionManager.selectedProfile;
308 profiles.appendChild(noneOption);
309
310 for (const profile of extension_settings.connectionManager.profiles) {
311 const option = document.createElement('option');
312 option.value = profile.id;
313 option.textContent = profile.name;
314 option.selected = profile.id === extension_settings.connectionManager.selectedProfile;
315 profiles.appendChild(option);
316 }
317}
318
319/**
320 * Renders the content of the details element.
321 * @param {HTMLElement} detailsContent Content element of the details
322 */
323async function renderDetailsContent(detailsContent) {
324 detailsContent.innerHTML = '';
325 if (detailsContent.classList.contains('hidden')) {
326 return;
327 }
328 const selectedProfile = extension_settings.connectionManager.selectedProfile;
329 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
330 if (profile) {
331 const profileForDisplay = makeFancyProfile(profile);
332 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'view', { profile: profileForDisplay });
333 detailsContent.innerHTML = template;
334 } else {
335 detailsContent.textContent = 'No profile selected';
336 }
337}
338
339(async function () {
340 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
341
342 for (const key of Object.keys(DEFAULT_SETTINGS)) {
343 if (extension_settings.connectionManager[key] === undefined) {
344 extension_settings.connectionManager[key] = DEFAULT_SETTINGS[key];
345 }
346 }
347
348 const container = document.getElementById('rm_api_block');
349 const settings = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
350 container.insertAdjacentHTML('afterbegin', settings);
351
352 /** @type {HTMLSelectElement} */
353 // @ts-ignore
354 const profiles = document.getElementById('connection_profiles');
355 renderConnectionProfiles(profiles);
356
357 function toggleProfileSpecificButtons() {
358 const profileId = extension_settings.connectionManager.selectedProfile;
359 const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];
360 profileSpecificButtons.forEach(id => document.getElementById(id).classList.toggle('disabled', !profileId));
361 }
362 toggleProfileSpecificButtons();
363
364 profiles.addEventListener('change', async function () {
365 const selectedProfile = profiles.selectedOptions[0];
366 if (!selectedProfile) {
367 // Safety net for preventing the command getting stuck
368 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
369 return;
370 }
371
372 const profileId = selectedProfile.value;
373 extension_settings.connectionManager.selectedProfile = profileId;
374 saveSettingsDebounced();
375 await renderDetailsContent(detailsContent);
376
377 toggleProfileSpecificButtons();
378
379 // None option selected
380 if (!profileId) {
381 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
382 return;
383 }
384
385 const profile = extension_settings.connectionManager.profiles.find(p => p.id === profileId);
386
387 if (!profile) {
388 console.log(`Profile not found: ${profileId}`);
389 return;
390 }
391
392 await applyConnectionProfile(profile);
393 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
394 });
395
396 const reloadButton = document.getElementById('reload_connection_profile');
397 reloadButton.addEventListener('click', async () => {
398 const selectedProfile = extension_settings.connectionManager.selectedProfile;
399 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
400 if (!profile) {
401 console.log('No profile selected');
402 return;
403 }
404 await applyConnectionProfile(profile);
405 await renderDetailsContent(detailsContent);
406 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
407 toastr.success('Connection profile reloaded', '', { timeOut: 1500 });
408 });
409
410 const createButton = document.getElementById('create_connection_profile');
411 createButton.addEventListener('click', async () => {
412 const profile = await createConnectionProfile();
413 if (!profile) {
414 return;
415 }
416 extension_settings.connectionManager.profiles.push(profile);
417 extension_settings.connectionManager.selectedProfile = profile.id;
418 saveSettingsDebounced();
419 renderConnectionProfiles(profiles);
420 await renderDetailsContent(detailsContent);
421 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
422 });
423
424 const updateButton = document.getElementById('update_connection_profile');
425 updateButton.addEventListener('click', async () => {
426 const selectedProfile = extension_settings.connectionManager.selectedProfile;
427 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
428 if (!profile) {
429 console.log('No profile selected');
430 return;
431 }
432 await updateConnectionProfile(profile);
433 await renderDetailsContent(detailsContent);
434 saveSettingsDebounced();
435 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
436 toastr.success('Connection profile updated', '', { timeOut: 1500 });
437 });
438
439 const deleteButton = document.getElementById('delete_connection_profile');
440 deleteButton.addEventListener('click', async () => {
441 await deleteConnectionProfile();
442 renderConnectionProfiles(profiles);
443 await renderDetailsContent(detailsContent);
444 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
445 });
446
447 /** @type {HTMLElement} */
448 const viewDetails = document.getElementById('view_connection_profile');
449 const detailsContent = document.getElementById('connection_profile_details_content');
450 viewDetails.addEventListener('click', async () => {
451 viewDetails.classList.toggle('active');
452 detailsContent.classList.toggle('hidden');
453 await renderDetailsContent(detailsContent);
454 });
455
456 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
457 name: 'profile',
458 helpString: 'Switch to a connection profile or return the name of the current profile in no argument is provided. Use <code>&lt;None&gt;</code> to switch to no profile.',
459 returns: 'name of the profile',
460 unnamedArgumentList: [
461 SlashCommandArgument.fromProps({
462 description: 'Name of the connection profile',
463 enumProvider: profilesProvider,
464 isRequired: false,
465 }),
466 ],
467 namedArgumentList: [
468 SlashCommandNamedArgument.fromProps({
469 name: 'await',
470 description: 'Wait for the connection profile to be applied before returning.',
471 isRequired: false,
472 typeList: [ARGUMENT_TYPE.BOOLEAN],
473 defaultValue: 'true',
474 enumList: commonEnumProviders.boolean('trueFalse')(),
475 }),
476 ],
477 callback: async (args, value) => {
478 if (!value || typeof value !== 'string') {
479 const selectedProfile = extension_settings.connectionManager.selectedProfile;
480 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
481 if (!profile) {
482 return NONE;
483 }
484 return profile.name;
485 }
486
487 if (value === NONE) {
488 profiles.selectedIndex = 0;
489 profiles.dispatchEvent(new Event('change'));
490 return NONE;
491 }
492
493 const profile = findProfileByName(value);
494
495 if (!profile) {
496 return '';
497 }
498
499 const shouldAwait = !isFalseBoolean(String(args?.await));
500 const awaitPromise = new Promise((resolve) => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
501
502 profiles.selectedIndex = Array.from(profiles.options).findIndex(o => o.value === profile.id);
503 profiles.dispatchEvent(new Event('change'));
504
505 if (shouldAwait) {
506 await awaitPromise;
507 }
508
509 return profile.name;
510 },
511 }));
512
513 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
514 name: 'profile-list',
515 helpString: 'List all connection profile names.',
516 returns: 'list of profile names',
517 callback: () => JSON.stringify(extension_settings.connectionManager.profiles.map(p => p.name)),
518 }));
519
520 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
521 name: 'profile-create',
522 returns: 'name of the new profile',
523 helpString: 'Create a new connection profile using the current settings.',
524 unnamedArgumentList: [
525 SlashCommandArgument.fromProps({
526 description: 'name of the new connection profile',
527 isRequired: true,
528 typeList: [ARGUMENT_TYPE.STRING],
529 }),
530 ],
531 callback: async (_args, name) => {
532 if (!name || typeof name !== 'string') {
533 toastr.warning('Please provide a name for the new connection profile.');
534 return '';
535 }
536 const profile = await createConnectionProfile(name);
537 if (!profile) {
538 return '';
539 }
540 extension_settings.connectionManager.profiles.push(profile);
541 extension_settings.connectionManager.selectedProfile = profile.id;
542 saveSettingsDebounced();
543 renderConnectionProfiles(profiles);
544 await renderDetailsContent(detailsContent);
545 return profile.name;
546 },
547 }));
548
549 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
550 name: 'profile-update',
551 helpString: 'Update the selected connection profile.',
552 callback: async () => {
553 const selectedProfile = extension_settings.connectionManager.selectedProfile;
554 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
555 if (!profile) {
556 toastr.warning('No profile selected.');
557 return '';
558 }
559 await updateConnectionProfile(profile);
560 await renderDetailsContent(detailsContent);
561 saveSettingsDebounced();
562 return profile.name;
563 },
564 }));
565
566 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
567 name: 'profile-get',
568 helpString: 'Get the details of the connection profile. Returns the selected profile if no argument is provided.',
569 returns: 'object of the selected profile',
570 unnamedArgumentList: [
571 SlashCommandArgument.fromProps({
572 description: 'Name of the connection profile',
573 enumProvider: profilesProvider,
574 isRequired: false,
575 }),
576 ],
577 callback: async (_args, value) => {
578 if (!value || typeof value !== 'string') {
579 const selectedProfile = extension_settings.connectionManager.selectedProfile;
580 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
581 if (!profile) {
582 return '';
583 }
584 return JSON.stringify(profile);
585 }
586
587 const profile = findProfileByName(value);
588 if (!profile) {
589 return '';
590 }
591 return JSON.stringify(profile);
592 },
593 }));
594})();
public/scripts/extensions/connection-manager/manifest.json+11 -0
@@ -0,0 +1,11 @@
1{
2 "display_name": "Connection Profiles",
3 "loading_order": 1,
4 "requires": [],
5 "optional": [],
6 "js": "index.js",
7 "css": "style.css",
8 "author": "Cohee1207",
9 "version": "1.0.0",
10 "homePage": "https://github.com/SillyTavern/SillyTavern"
11}
public/scripts/extensions/connection-manager/profile.html+13 -0
@@ -0,0 +1,13 @@
1<div>
2 <h2 data-i18n="Creating a Connection Profile">
3 Creating a Connection Profile
4 </h2>
5 <ul class="justifyLeft">
6 {{#each profile}}
7 <li><strong data-i18n="{{@key}}">{{@key}}:</strong>&nbsp;{{this}}</li>
8 {{/each}}
9 </ul>
10 <h3 data-i18n="Enter a name:">
11 Enter a name:
12 </h3>
13</div>
public/scripts/extensions/connection-manager/settings.html+18 -0
@@ -0,0 +1,18 @@
1<div class="wide100p">
2 <div class="flex-container alignItemsBaseline">
3 <h3 data-i18n="Connection Profile" class="margin0">
4 Connection Profile
5 </h3>
6 <div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]Save connection profiles to quickly switch between different APIs, models and formatting templates." title="Save connection profiles to quickly switch between different APIs, models and formatting templates."></div>
7 <i id="connection_profile_spinner" class="fa-solid fa-spinner fa-spin hidden"></i>
8 </div>
9 <div class="flex-container">
10 <select class="text_pole flex1" id="connection_profiles"></select>
11 <i id="view_connection_profile" class="menu_button fa-solid fa-info-circle" title="View connection profile details" data-i18n="[title]View connection profile details"></i>
12 <i id="create_connection_profile" class="menu_button fa-solid fa-file-circle-plus" title="Create a new connection profile" data-i18n="[title]Create a new connection profile"></i>
13 <i id="update_connection_profile" class="menu_button fa-solid fa-save" title="Update a connection profile" data-i18n="[title]Update a connection profile"></i>
14 <i id="reload_connection_profile" class="menu_button fa-solid fa-recycle" title="Reload a connection profile" data-i18n="[title]Reload a connection profile"></i>
15 <i id="delete_connection_profile" class="menu_button fa-solid fa-trash-can" title="Delete a connection profile" data-i18n="[title]Delete a connection profile"></i>
16 </div>
17 <div id="connection_profile_details_content" class="hidden"></div>
18</div>
public/scripts/extensions/connection-manager/style.css+11 -0
@@ -0,0 +1,11 @@
1#connection_profile_details_content {
2 margin: 5px 0;
3}
4
5#connection_profile_details_content ul {
6 margin: 0;
7}
8
9#connection_profile_spinner {
10 margin-left: 5px;
11}
public/scripts/extensions/connection-manager/view.html+5 -0
@@ -0,0 +1,5 @@
1<ul>
2 {{#each profile}}
3 <li><strong data-i18n="{{@key}}">{{@key}}:</strong>&nbsp;{{this}}</li>
4 {{/each}}
5</ul>
public/scripts/instruct-mode.js+14 -10
@@ -130,13 +130,15 @@ function highlightDefaultPreset() {
130/**130/**
131 * Select context template if not already selected.131 * Select context template if not already selected.
132 * @param {string} preset Preset name.132 * @param {string} preset Preset name.
133 * @param {boolean} quiet Suppress info message.133 * @param {object} [options={}] Optional arguments.
134 * @param {boolean} [options.quiet=false] Suppress toast messages.
135 * @param {boolean} [options.isAuto=false] Is auto-select.
134 */136 */
135export function selectContextPreset(preset, quiet) {137export function selectContextPreset(preset, { quiet = false, isAuto = false } = {}) {
136 // If context template is not already selected, select it138 // If context template is not already selected, select it
137 if (preset !== power_user.context.preset) {139 if (preset !== power_user.context.preset) {
138 $('#context_presets').val(preset).trigger('change');140 $('#context_presets').val(preset).trigger('change');
139 !quiet && toastr.info(`Context Template: preset "${preset}" auto-selected`);141 !quiet && toastr.info(`Context Template: "${preset}" ${isAuto ? 'auto-' : ''}selected`);
140 }142 }
141143
142 // If instruct mode is disabled, enable it, except for default context template144 // If instruct mode is disabled, enable it, except for default context template
@@ -152,13 +154,15 @@ export function selectContextPreset(preset, quiet) {
152/**154/**
153 * Select instruct preset if not already selected.155 * Select instruct preset if not already selected.
154 * @param {string} preset Preset name.156 * @param {string} preset Preset name.
155 * @param {boolean} quiet Suppress info message.157 * @param {object} [options={}] Optional arguments.
158 * @param {boolean} [options.quiet=false] Suppress toast messages.
159 * @param {boolean} [options.isAuto=false] Is auto-select.
156 */160 */
157export function selectInstructPreset(preset, quiet) {161export function selectInstructPreset(preset, { quiet = false, isAuto = false } = {}) {
158 // If instruct preset is not already selected, select it162 // If instruct preset is not already selected, select it
159 if (preset !== power_user.instruct.preset) {163 if (preset !== power_user.instruct.preset) {
160 $('#instruct_presets').val(preset).trigger('change');164 $('#instruct_presets').val(preset).trigger('change');
161 !quiet && toastr.info(`Instruct Mode: template "${preset}" auto-selected`);165 !quiet && toastr.info(`Instruct Template: "${preset}" ${isAuto ? 'auto-' : ''}selected`);
162 }166 }
163167
164 // If instruct mode is disabled, enable it168 // If instruct mode is disabled, enable it
@@ -189,7 +193,7 @@ export function autoSelectInstructPreset(modelId) {
189 // If instruct preset matches the context template193 // If instruct preset matches the context template
190 if (power_user.instruct.bind_to_context && instruct_preset.name === power_user.context.preset) {194 if (power_user.instruct.bind_to_context && instruct_preset.name === power_user.context.preset) {
191 foundMatch = true;195 foundMatch = true;
192 selectInstructPreset(instruct_preset.name);196 selectInstructPreset(instruct_preset.name, { isAuto: true });
193 break;197 break;
194 }198 }
195 }199 }
@@ -203,7 +207,7 @@ export function autoSelectInstructPreset(modelId) {
203207
204 // Stop on first match so it won't cycle back and forth between presets if multiple regexes match208 // Stop on first match so it won't cycle back and forth between presets if multiple regexes match
205 if (regex instanceof RegExp && regex.test(modelId)) {209 if (regex instanceof RegExp && regex.test(modelId)) {
206 selectInstructPreset(preset.name);210 selectInstructPreset(preset.name, { isAuto: true });
207211
208 return true;212 return true;
209 }213 }
@@ -541,13 +545,13 @@ function selectMatchingContextTemplate(name) {
541 // If context template matches the instruct preset545 // If context template matches the instruct preset
542 if (context_preset.name === name) {546 if (context_preset.name === name) {
543 foundMatch = true;547 foundMatch = true;
544 selectContextPreset(context_preset.name);548 selectContextPreset(context_preset.name, { isAuto: true });
545 break;549 break;
546 }550 }
547 }551 }
548 if (!foundMatch) {552 if (!foundMatch) {
549 // If no match was found, select default context preset553 // If no match was found, select default context preset
550 selectContextPreset(power_user.default_context);554 selectContextPreset(power_user.default_context, { isAuto: true });
551 }555 }
552}556}
553557
public/scripts/power-user.js+1 -1
@@ -1798,7 +1798,7 @@ async function loadContextSettings() {
1798 for (const instruct_preset of instruct_presets) {1798 for (const instruct_preset of instruct_presets) {
1799 // If instruct preset matches the context template1799 // If instruct preset matches the context template
1800 if (instruct_preset.name === name) {1800 if (instruct_preset.name === name) {
1801 selectInstructPreset(instruct_preset.name);1801 selectInstructPreset(instruct_preset.name, { isAuto: true });
1802 break;1802 break;
1803 }1803 }
1804 }1804 }
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+1 -0
@@ -33,6 +33,7 @@ export const enumIcons = {
33 file: 'πŸ“„',33 file: 'πŸ“„',
34 message: 'πŸ’¬',34 message: 'πŸ’¬',
35 voice: '🎀',35 voice: '🎀',
36 server: 'πŸ–₯️',
3637
37 true: 'βœ”οΈ',38 true: 'βœ”οΈ',
38 false: '❌',39 false: '❌',
public/scripts/util/AbortReason.js+9 -0
@@ -0,0 +1,9 @@
1export class AbortReason {
2 constructor(reason) {
3 this.reason = reason;
4 }
5
6 toString() {
7 return this.reason;
8 }
9}
public/scripts/utils.js+9 -0
@@ -1436,6 +1436,15 @@ export function uuidv4() {
1436 });1436 });
1437}1437}
14381438
1439/**
1440 * Collapses multiple spaces in a strings into one.
1441 * @param {string} s String to process
1442 * @returns {string} String with collapsed spaces
1443 */
1444export function collapseSpaces(s) {
1445 return s.replace(/\s+/g, ' ').trim();
1446}
1447
1439function postProcessText(text, collapse = true) {1448function postProcessText(text, collapse = true) {
1440 // Remove carriage returns1449 // Remove carriage returns
1441 text = text.replace(/\r/g, '');1450 text = text.replace(/\r/g, '');