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

d9ea3d48dd00ab1ac9aba3e72cefe6f3a548f9f6

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

Signed
13 files changed, +708 -21Ignore whitespace
public/script.js+14 -7
@@ -242,6 +242,7 @@ import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js'
242242import { initDynamicStyles } from './scripts/dynamic-styles.js';
243243import { SlashCommandEnumValue, enumTypes } from './scripts/slash-commands/SlashCommandEnumValue.js';
244244import { commonEnumProviders, enumIcons } from './scripts/slash-commands/SlashCommandCommonEnumsProvider.js';
245+import { AbortReason } from './scripts/util/AbortReason.js';
245246
246247//exporting functions and vars for mods
247248export {
@@ -462,6 +463,7 @@ export const event_types = {
462463 LLM_FUNCTION_TOOL_CALL: 'llm_function_tool_call',
463464 ONLINE_STATUS_CHANGED: 'online_status_changed',
464465 IMAGE_SWIPED: 'image_swiped',
466+ CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
465467};
466468
467469export const eventSource = new EventEmitter();
@@ -977,8 +979,8 @@ async function fixViewport() {
977979 document.body.style.position = '';
978980}
979981
980982function cancelStatusCheck(reason = 'Manually cancelled status check') {
981983 abortStatusCheck?.abort(new AbortReason(reason));
982984 abortStatusCheck = new AbortController();
983985 setOnlineStatus('no_connection');
984986}
@@ -1228,7 +1230,12 @@ async function getStatusTextgen() {
12281230 toastr.error(data.response, 'API Error', { timeOut: 5000, preventDuplicates: true });
12291231 }
12301232 } catch (err) {
1231- console.error('Error getting status', err);
1233+ if (err instanceof AbortReason) {
1234+ console.info('Status check aborted.', err.reason);
1235+ } else {
1236+ console.error('Error getting status', err);
1237+
1238+ }
12321239 setOnlineStatus('no_connection');
12331240 }
12341241
@@ -8519,7 +8526,7 @@ async function selectContextCallback(args, name) {
85198526 }
85208527
85218528 const foundName = result[0].item;
85228529 selectContextPreset(foundName, { quiet: quiet });
85238530 return foundName;
85248531}
85258532
@@ -8539,7 +8546,7 @@ async function selectInstructCallback(args, name) {
85398546 }
85408547
85418548 const foundName = result[0].item;
85428549 selectInstructPreset(foundName, { quiet: quiet });
85438550 return foundName;
85448551}
85458552
@@ -9316,7 +9323,7 @@ jQuery(async function () {
93169323 $('#groupCurrentMemberListToggle .inline-drawer-icon').trigger('click');
93179324 }, 200);
93189325
93199326 $(document).on('click', '.api_loading', () => cancelStatusCheck('Canceled because connecting was manually canceled'));
93209327
93219328 //////////INPUT BAR FOCUS-KEEPING LOGIC/////////////
93229329 let S_TAPreviouslyFocused = false;
@@ -10075,7 +10082,7 @@ jQuery(async function () {
1007510082 });
1007610083
1007710084 $('#main_api').change(function () {
10078- cancelStatusCheck();
10085+ cancelStatusCheck('Canceled because main api changed');
1007910086 changeMainAPI();
1008010087 saveSettingsDebounced();
1008110088 });
public/scripts/extensions.js+5 -0
@@ -123,6 +123,11 @@ const extension_settings = {
123123 /** @type {string[]} */
124124 custom: [],
125125 },
126+ connectionManager: {
127+ selectedProfile: '',
128+ /** @type {import('./extensions/connection-manager/index.js').ConnectionProfile[]} */
129+ profiles: [],
130+ },
126131 dice: {},
127132 /** @type {import('./char-data.js').RegexScriptData[]} */
128133 regex: [],
public/scripts/extensions/connection-manager/index.js+594 -0
@@ -0,0 +1,594 @@
1+import { event_types, eventSource, 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+import { SlashCommand } from '../../slash-commands/SlashCommand.js';
6+import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
7+import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
8+import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
9+import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
10+import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js';
11+
12+const MODULE_NAME = 'connection-manager';
13+const NONE = '<None>';
14+
15+const DEFAULT_SETTINGS = {
16+ profiles: [],
17+ selectedProfile: null,
18+};
19+
20+const COMMON_COMMANDS = [
21+ 'api',
22+ 'preset',
23+ 'api-url',
24+ 'model',
25+];
26+
27+const CC_COMMANDS = [
28+ ...COMMON_COMMANDS,
29+ 'proxy',
30+];
31+
32+const TC_COMMANDS = [
33+ ...COMMON_COMMANDS,
34+ 'instruct',
35+ 'context',
36+ 'instruct-state',
37+ 'tokenizer',
38+];
39+
40+const 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+ */
55+class 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[]} */
95+const 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+
115+const 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+ */
122+function 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+ */
148+async 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+ */
180+async 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+ */
213+async 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+ */
241+function 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+ */
254+async 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+ */
292+async 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+ */
301+function 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+ */
323+async 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() {
130130/**
131131 * Select context template if not already selected.
132132 * @param {string} preset Preset name.
133133 * @param {booleanobject} quiet Suppress[options={}] infoOptional messagearguments.
134+ * @param {boolean} [options.quiet=false] Suppress toast messages.
135+ * @param {boolean} [options.isAuto=false] Is auto-select.
134136 */
135137export function selectContextPreset(preset, { quiet = false, isAuto = false } = {}) {
136138 // If context template is not already selected, select it
137139 if (preset !== power_user.context.preset) {
138140 $('#context_presets').val(preset).trigger('change');
139141 !quiet && toastr.info(`Context Template: preset "${preset}" ${isAuto ? 'auto-' : ''}selected`);
140142 }
141143
142144 // If instruct mode is disabled, enable it, except for default context template
@@ -152,13 +154,15 @@ export function selectContextPreset(preset, quiet) {
152154/**
153155 * Select instruct preset if not already selected.
154156 * @param {string} preset Preset name.
155157 * @param {booleanobject} quiet Suppress[options={}] infoOptional messagearguments.
158+ * @param {boolean} [options.quiet=false] Suppress toast messages.
159+ * @param {boolean} [options.isAuto=false] Is auto-select.
156160 */
157161export function selectInstructPreset(preset, { quiet = false, isAuto = false } = {}) {
158162 // If instruct preset is not already selected, select it
159163 if (preset !== power_user.instruct.preset) {
160164 $('#instruct_presets').val(preset).trigger('change');
161165 !quiet && toastr.info(`Instruct ModeTemplate: template "${preset}" ${isAuto ? 'auto-' : ''}selected`);
162166 }
163167
164168 // If instruct mode is disabled, enable it
@@ -189,7 +193,7 @@ export function autoSelectInstructPreset(modelId) {
189193 // If instruct preset matches the context template
190194 if (power_user.instruct.bind_to_context && instruct_preset.name === power_user.context.preset) {
191195 foundMatch = true;
192196 selectInstructPreset(instruct_preset.name, { isAuto: true });
193197 break;
194198 }
195199 }
@@ -203,7 +207,7 @@ export function autoSelectInstructPreset(modelId) {
203207
204208 // Stop on first match so it won't cycle back and forth between presets if multiple regexes match
205209 if (regex instanceof RegExp && regex.test(modelId)) {
206210 selectInstructPreset(preset.name, { isAuto: true });
207211
208212 return true;
209213 }
@@ -541,13 +545,13 @@ function selectMatchingContextTemplate(name) {
541545 // If context template matches the instruct preset
542546 if (context_preset.name === name) {
543547 foundMatch = true;
544548 selectContextPreset(context_preset.name, { isAuto: true });
545549 break;
546550 }
547551 }
548552 if (!foundMatch) {
549553 // If no match was found, select default context preset
550554 selectContextPreset(power_user.default_context, { isAuto: true });
551555 }
552556}
553557
public/scripts/power-user.js+1 -1
@@ -1798,7 +1798,7 @@ async function loadContextSettings() {
17981798 for (const instruct_preset of instruct_presets) {
17991799 // If instruct preset matches the context template
18001800 if (instruct_preset.name === name) {
18011801 selectInstructPreset(instruct_preset.name, { isAuto: true });
18021802 break;
18031803 }
18041804 }
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+1 -0
@@ -33,6 +33,7 @@ export const enumIcons = {
3333 file: '📄',
3434 message: '💬',
3535 voice: '🎤',
36+ server: '🖥️',
3637
3738 true: '✔️',
3839 false: '❌',
public/scripts/util/AbortReason.js+9 -0
@@ -0,0 +1,9 @@
1+export class AbortReason {
2+ constructor(reason) {
3+ this.reason = reason;
4+ }
5+
6+ toString() {
7+ return this.reason;
8+ }
9+}
public/scripts/utils.js+12 -3
@@ -1436,6 +1436,15 @@ export function uuidv4() {
14361436 });
14371437}
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+ */
1444+export function collapseSpaces(s) {
1445+ return s.replace(/\s+/g, ' ').trim();
1446+}
1447+
14391448function postProcessText(text, collapse = true) {
14401449 // Remove carriage returns
14411450 text = text.replace(/\r/g, '');
@@ -2041,7 +2050,7 @@ export async function fetchFaFile(name) {
20412050 style.remove();
20422051 return [...sheet.cssRules]
20432052 .filter(rule => rule.style?.content)
20442053 .map(rule => rule.selectorText.split(/,\s*/).map(selector => selector.split('::').shift().slice(1)))
20452054 ;
20462055}
20472056export async function fetchFa() {
@@ -2068,7 +2077,7 @@ export async function showFontAwesomePicker(customList = null) {
20682077 qry.placeholder = 'Filter icons';
20692078 qry.autofocus = true;
20702079 const qryDebounced = debounce(() => {
20712080 const result = faList.filter(fa => fa.find(className => className.includes(qry.value.toLowerCase())));
20722081 for (const fa of faList) {
20732082 if (!result.includes(fa)) {
20742083 fas[fa].classList.add('hidden');
@@ -2090,7 +2099,7 @@ export async function showFontAwesomePicker(customList = null) {
20902099 opt.classList.add('menu_button');
20912100 opt.classList.add('fa-solid');
20922101 opt.classList.add(fa[0]);
20932102 opt.title = fa.map(it => it.slice(3)).join(', ');
20942103 opt.dataset.result = POPUP_RESULT.AFFIRMATIVE.toString();
20952104 opt.addEventListener('click', () => value = fa[0]);
20962105 grid.append(opt);