Add connection manager as a core extension

7dc1c9f7ab98346495f66cbc5a6233248eba282a

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

7 files changed, +386 -0Showing whitespace changes
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+326 -0
@@ -0,0 +1,326 @@
1import { 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';
5
6const MODULE_NAME = 'connection-manager';
7
8const DEFAULT_SETTINGS = {
9 profiles: [],
10 selectedProfile: null,
11};
12
13const COMMON_COMMANDS = [
14 'api',
15 'preset',
16 'model',
17];
18
19const CC_COMMANDS = [
20 ...COMMON_COMMANDS,
21 'proxy',
22];
23
24const TC_COMMANDS = [
25 ...COMMON_COMMANDS,
26 'instruct',
27 'context',
28 'instruct-state',
29 'tokenizer',
30];
31
32const 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
58const 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 */
66async 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 */
97async 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 */
124async 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 */
151function 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 */
164async 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 */
190async 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 */
206function 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 */
229async 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>