Add connection manager as a core extension

7dc1c9f7ab98346495f66cbc5a6233248eba282a

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

7 files changed, +386 -0Ignore whitespace
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+326 -0
@@ -0,0 +1,326 @@
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+})();
public/scripts/extensions/connection-manager/manifest.json+11 -0
@@ -0,0 +1,11 @@
1+{
2+ "display_name": "Connection Manager",
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+9 -0
@@ -0,0 +1,9 @@
1+<div>
2+ <h2>Creating a Connection Profile</h2>
3+ <ul class="justifyLeft">
4+ {{#each profile}}
5+ <li><strong>{{@key}}:</strong>&nbsp;{{this}}</li>
6+ {{/each}}
7+ </ul>
8+ <h3>Enter a name:</h3>
9+</div>
public/scripts/extensions/connection-manager/settings.html+21 -0
@@ -0,0 +1,21 @@
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+ </div>
8+ <div class="flex-container">
9+ <select class="text_pole flex1" id="connection_profiles"></select>
10+ <i id="create_connection_profile" class="menu_button fa-solid fa-plus" title="Create a new connection profile" data-i18n="[title]Create a new connection profile"></i>
11+ <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>
12+ <i id="reload_connection_profile" class="menu_button fa-solid fa-sync" title="Reload a connection profile" data-i18n="[title]Reload a connection profile"></i>
13+ <i id="delete_connection_profile" class="menu_button fa-solid fa-trash" title="Delete a connection profile" data-i18n="[title]Delete a connection profile"></i>
14+ </div>
15+ <details id="connection_profile_details" class="marginBot10">
16+ <summary data-i18n="Profile Details">
17+ Profile Details
18+ </summary>
19+ <div id="connection_profile_details_content" class="marginTop5"></div>
20+ </details>
21+</div>
public/scripts/extensions/connection-manager/style.css+9 -0
@@ -0,0 +1,9 @@
1+#connection_profile_details>summary {
2+ cursor: pointer;
3+ font-weight: bold;
4+ font-size: 1.1em;
5+}
6+
7+#connection_profile_details ul {
8+ margin: 5px 0;
9+}
public/scripts/extensions/connection-manager/view.html+5 -0
@@ -0,0 +1,5 @@
1+<ul>
2+ {{#each profile}}
3+ <li><strong>{{@key}}:</strong>&nbsp;{{this}}</li>
4+ {{/each}}
5+</ul>