Blame Raw
permissionBRICK · c0a14545 · · 1292 lines (49.2 KB)
3 contributors
1import { DOMPurify, Fuse, Popper } from '../../../lib.js';
2
3import { activateSendButtons, animation_duration, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
4import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
6import { SlashCommand } from '../../slash-commands/SlashCommand.js';
7import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
8import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
9import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
10import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
11import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
12import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
13import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
14import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
15import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
16import { t } from '../../i18n.js';
17import { getSecretLabelById } from '../../secrets.js';
18import { performFuzzySearch } from '/scripts/power-user.js';
19import { StreamingDisplay } from '/scripts/streaming-display.js';
20import { ConnectionManagerRequestService } from '../shared.js';
21import { formatReasoning } from '/scripts/reasoning.js';
22
23const MODULE_NAME = 'connection-manager';
24const NONE = '<None>';
25const EMPTY = '<Empty>';
26
27const DEFAULT_SETTINGS = {
28 profiles: [],
29 selectedProfile: null,
30};
31
32// Commands that can record an empty value into the profile
33const ALLOW_EMPTY = [
34 'stop-strings',
35 'start-reply-with',
36];
37
38const CC_COMMANDS = [
39 'api',
40 'preset',
41 // Do not fix; CC needs to set the API twice because it could be overridden by the preset
42 'api',
43 'api-url',
44 'model',
45 'proxy',
46 'stop-strings',
47 'start-reply-with',
48 'reasoning-template',
49 'prompt-post-processing',
50 'secret-id',
51 'regex-preset',
52];
53
54const TC_COMMANDS = [
55 'api',
56 'preset',
57 'api-url',
58 'model',
59 'sysprompt',
60 'sysprompt-state',
61 'instruct',
62 'context',
63 'instruct-state',
64 'tokenizer',
65 'stop-strings',
66 'start-reply-with',
67 'reasoning-template',
68 'secret-id',
69 'regex-preset',
70];
71
72const FANCY_NAMES = {
73 'api': 'API',
74 'api-url': 'Server URL',
75 'preset': 'Settings Preset',
76 'model': 'Model',
77 'proxy': 'Proxy Preset',
78 'sysprompt-state': 'Use System Prompt',
79 'sysprompt': 'System Prompt Name',
80 'instruct-state': 'Instruct Mode',
81 'instruct': 'Instruct Template',
82 'context': 'Context Template',
83 'tokenizer': 'Tokenizer',
84 'stop-strings': 'Custom Stopping Strings',
85 'start-reply-with': 'Start Reply With',
86 'reasoning-template': 'Reasoning Template',
87 'prompt-post-processing': 'Prompt Post-Processing',
88 'secret-id': 'Secret',
89 'regex-preset': 'Regex Preset',
90};
91
92/**
93 * A wrapper for the connection manager spinner.
94 */
95class ConnectionManagerSpinner {
96 /**
97 * @type {AbortController[]}
98 */
99 static abortControllers = [];
100
101 /** @type {HTMLElement} */
102 spinnerElement;
103
104 /** @type {AbortController} */
105 abortController = new AbortController();
106
107 constructor() {
108 // @ts-ignore
109 this.spinnerElement = document.getElementById('connection_profile_spinner');
110 this.abortController = new AbortController();
111 }
112
113 start() {
114 ConnectionManagerSpinner.abortControllers.push(this.abortController);
115 this.spinnerElement.classList.remove('hidden');
116 }
117
118 stop() {
119 this.spinnerElement.classList.add('hidden');
120 }
121
122 isAborted() {
123 return this.abortController.signal.aborted;
124 }
125
126 static abort() {
127 for (const controller of ConnectionManagerSpinner.abortControllers) {
128 controller.abort();
129 }
130 ConnectionManagerSpinner.abortControllers = [];
131 }
132}
133
134/**
135 * Get named arguments for the command callback.
136 * @param {object} [args] Additional named arguments
137 * @param {string} [args.force] Whether to force setting the value
138 * @returns {object} Named arguments
139 */
140function getNamedArguments(args = {}) {
141 // None of the commands here use underscored args, but better safe than sorry
142 return {
143 _scope: new SlashCommandScope(),
144 _abortController: new SlashCommandAbortController(),
145 _debugController: new SlashCommandDebugController(),
146 _parserFlags: {},
147 _hasUnnamedArgument: false,
148 quiet: 'true',
149 ...args,
150 };
151}
152
153/** @type {() => SlashCommandEnumValue[]} */
154const profilesProvider = () => [
155 new SlashCommandEnumValue(NONE),
156 ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
157];
158
159/**
160 * @typedef {Object} ConnectionProfile
161 * @property {string} id Unique identifier
162 * @property {string} mode Mode of the connection profile
163 * @property {string} [name] Name of the connection profile
164 * @property {string} [api] API
165 * @property {string} [preset] Settings Preset
166 * @property {string} [model] Model
167 * @property {string} [proxy] Proxy Preset
168 * @property {string} [instruct] Instruct Template
169 * @property {string} [context] Context Template
170 * @property {string} [instruct-state] Instruct Mode
171 * @property {string} [tokenizer] Tokenizer
172 * @property {string} [stop-strings] Custom Stopping Strings
173 * @property {string} [start-reply-with] Start Reply With
174 * @property {string} [reasoning-template] Reasoning Template
175 * @property {string} [prompt-post-processing] Prompt Post-Processing
176 * @property {string} [sysprompt] System Prompt Name
177 * @property {string} [sysprompt-state] Use System Prompt
178 * @property {string} [api-url] Server URL
179 * @property {string} [secret-id] Secret ID
180 * @property {string} [regex-preset] Regex Preset ID
181 * @property {string[]} [exclude] Commands to exclude
182 */
183
184/**
185 * Finds the best match for the search value.
186 * @param {string} value Search value
187 * @returns {ConnectionProfile|null} Best match or null
188 */
189function findProfileByName(value) {
190 // Try to find exact match
191 const profile = extension_settings.connectionManager.profiles.find(p => p.name === value);
192
193 if (profile) {
194 return profile;
195 }
196
197 // Try to find fuzzy match
198 const fuse = new Fuse(extension_settings.connectionManager.profiles, { keys: ['name'] });
199 const results = fuse.search(value);
200
201 if (results.length === 0) {
202 return null;
203 }
204
205 const bestMatch = results[0];
206 return bestMatch.item;
207}
208
209/**
210 * Reads the connection profile from the commands.
211 * @param {string} mode Mode of the connection profile
212 * @param {ConnectionProfile} profile Connection profile
213 * @param {boolean} [cleanUp] Whether to clean up the profile
214 */
215async function readProfileFromCommands(mode, profile, cleanUp = false) {
216 const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
217 const opposingCommands = mode === 'cc' ? TC_COMMANDS : CC_COMMANDS;
218 const excludeList = Array.isArray(profile.exclude) ? profile.exclude : [];
219 for (const command of commands) {
220 try {
221 if (excludeList.includes(command)) {
222 continue;
223 }
224
225 const allowEmpty = ALLOW_EMPTY.includes(command);
226 const args = getNamedArguments();
227 const result = await SlashCommandParser.commands[command].callback(args, '');
228 if (result || (allowEmpty && result === '')) {
229 profile[command] = result;
230 continue;
231 }
232 } catch (error) {
233 console.error(`Failed to execute command: ${command}`, error);
234 }
235 }
236
237 if (cleanUp) {
238 for (const command of commands) {
239 if (command.endsWith('-state') && profile[command] === 'false') {
240 delete profile[command.replace('-state', '')];
241 }
242 }
243 for (const command of opposingCommands) {
244 if (commands.includes(command)) {
245 continue;
246 }
247
248 delete profile[command];
249 }
250 }
251}
252
253/**
254 * Creates a new connection profile.
255 * @param {string} [forceName] Name of the connection profile
256 * @returns {Promise<ConnectionProfile>} Created connection profile
257 */
258async function createConnectionProfile(forceName = null) {
259 const mode = main_api === 'openai' ? 'cc' : 'tc';
260 const id = uuidv4();
261 /** @type {ConnectionProfile} */
262 const profile = {
263 id,
264 mode,
265 exclude: [],
266 };
267
268 await readProfileFromCommands(mode, profile);
269
270 const profileForDisplay = makeFancyProfile(profile);
271 const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'profile', { profile: profileForDisplay }));
272 template.find('input[name="exclude"]').on('input', function () {
273 const fancyName = String($(this).val());
274 const keyName = Object.entries(FANCY_NAMES).find(x => x[1] === fancyName)?.[0];
275 if (!keyName) {
276 console.warn('Key not found for fancy name:', fancyName);
277 return;
278 }
279
280 if (!Array.isArray(profile.exclude)) {
281 profile.exclude = [];
282 }
283
284 const excludeState = !$(this).prop('checked');
285 if (excludeState) {
286 profile.exclude.push(keyName);
287 } else {
288 const index = profile.exclude.indexOf(keyName);
289 index !== -1 && profile.exclude.splice(index, 1);
290 }
291 });
292 const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n);
293 const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken);
294 let name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName);
295 // If it's cancelled, it will be false
296 if (!name) {
297 return null;
298 }
299 name = DOMPurify.sanitize(String(name));
300 if (!name) {
301 toastr.error('Name cannot be empty.');
302 return null;
303 }
304
305 if (isNameTaken(name) || name === NONE) {
306 toastr.error('A profile with the same name already exists.');
307 return null;
308 }
309
310 if (Array.isArray(profile.exclude)) {
311 for (const command of profile.exclude) {
312 delete profile[command];
313 }
314 }
315
316 profile.name = String(name);
317 return profile;
318}
319
320/**
321 * Deletes the selected connection profile.
322 * @returns {Promise<void>}
323 */
324async function deleteConnectionProfile() {
325 const selectedProfile = extension_settings.connectionManager.selectedProfile;
326 if (!selectedProfile) {
327 return;
328 }
329
330 const index = extension_settings.connectionManager.profiles.findIndex(p => p.id === selectedProfile);
331 if (index === -1) {
332 return;
333 }
334
335 const profile = extension_settings.connectionManager.profiles[index];
336 const name = profile.name;
337 const confirm = await Popup.show.confirm(t`Are you sure you want to delete the selected profile?`, name);
338
339 if (!confirm) {
340 return;
341 }
342
343 extension_settings.connectionManager.profiles.splice(index, 1);
344 extension_settings.connectionManager.selectedProfile = null;
345 saveSettingsDebounced();
346
347 await eventSource.emit(event_types.CONNECTION_PROFILE_DELETED, profile);
348}
349
350/**
351 * Formats the connection profile for display.
352 * @param {ConnectionProfile} profile Connection profile
353 * @returns {Object} Fancy profile
354 */
355function makeFancyProfile(profile) {
356 return Object.entries(FANCY_NAMES).reduce((acc, [key, value]) => {
357 const allowEmpty = ALLOW_EMPTY.includes(key);
358 if (!profile[key]) {
359 if (profile[key] === '' && allowEmpty) {
360 acc[value] = EMPTY;
361 }
362 return acc;
363 }
364
365 // UUID is not very useful in the UI, so we replace it with a label (if available)
366 if (key === 'secret-id') {
367 const label = getSecretLabelById(profile[key]);
368 if (label) {
369 acc[value] = label;
370 return acc;
371 }
372 }
373
374 if (key === 'regex-preset') {
375 const label = extension_settings.regex_presets?.find(p => p.id === profile[key])?.name;
376 if (label) {
377 acc[value] = label;
378 return acc;
379 }
380 }
381
382 acc[value] = profile[key];
383 return acc;
384 }, {});
385}
386
387/**
388 * Applies the connection profile.
389 * @param {ConnectionProfile} profile Connection profile
390 * @returns {Promise<void>}
391 */
392async function applyConnectionProfile(profile) {
393 if (!profile) {
394 return;
395 }
396
397 // Abort any ongoing profile application
398 ConnectionManagerSpinner.abort();
399
400 const mode = profile.mode;
401 const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
402 const spinner = new ConnectionManagerSpinner();
403 spinner.start();
404
405 for (const command of commands) {
406 if (spinner.isAborted()) {
407 throw new Error('Profile application aborted');
408 }
409
410 const argument = profile[command];
411 const allowEmpty = ALLOW_EMPTY.includes(command);
412 if (!argument && !(allowEmpty && argument === '')) {
413 continue;
414 }
415 try {
416 const args = getNamedArguments(allowEmpty ? { force: 'true' } : {});
417 await SlashCommandParser.commands[command].callback(args, argument);
418 } catch (error) {
419 console.error(`Failed to execute command: ${command} ${argument}`, error);
420 }
421 }
422
423 spinner.stop();
424}
425
426/**
427 * Updates the selected connection profile.
428 * @param {ConnectionProfile} profile Connection profile
429 * @returns {Promise<void>}
430 */
431async function updateConnectionProfile(profile) {
432 profile.mode = main_api === 'openai' ? 'cc' : 'tc';
433 await readProfileFromCommands(profile.mode, profile, true);
434}
435
436/**
437 * Renders the connection profile details.
438 * @param {HTMLSelectElement} profiles Select element containing connection profiles
439 */
440function renderConnectionProfiles(profiles) {
441 profiles.innerHTML = '';
442 const noneOption = document.createElement('option');
443
444 noneOption.value = '';
445 noneOption.textContent = NONE;
446 noneOption.selected = !extension_settings.connectionManager.selectedProfile;
447 profiles.appendChild(noneOption);
448
449 for (const profile of extension_settings.connectionManager.profiles.sort((a, b) => a.name.localeCompare(b.name))) {
450 const option = document.createElement('option');
451 option.value = profile.id;
452 option.textContent = profile.name;
453 option.selected = profile.id === extension_settings.connectionManager.selectedProfile;
454 profiles.appendChild(option);
455 }
456}
457
458/**
459 * Renders the content of the details element.
460 * @param {HTMLElement} detailsContent Content element of the details
461 */
462async function renderDetailsContent(detailsContent) {
463 detailsContent.innerHTML = '';
464 if (detailsContent.classList.contains('hidden')) {
465 return;
466 }
467 const selectedProfile = extension_settings.connectionManager.selectedProfile;
468 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
469 if (profile) {
470 const profileForDisplay = makeFancyProfile(profile);
471 const templateParams = { profile: profileForDisplay };
472 if (Array.isArray(profile.exclude) && profile.exclude.length > 0) {
473 templateParams.omitted = profile.exclude.map(e => FANCY_NAMES[e]).join(', ');
474 }
475 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'view', templateParams);
476 detailsContent.innerHTML = template;
477 } else {
478 detailsContent.textContent = t`No profile selected`;
479 }
480}
481
482/**
483 * Callback for the /profile-genstream command
484 * Generates text using Connection Manager with streaming display support.
485 * @param {object} args Named arguments
486 * @param {string} value Unnamed argument (the prompt)
487 * @returns {Promise<string>} The generated text, optionally with formatted reasoning
488 */
489async function generateStreamCallback(args, value) {
490 if (!value) {
491 console.warn('WARN: No argument provided for /profile-genstream command');
492 return '';
493 }
494
495 // Check if Connection Manager is available
496 const context = getContext();
497 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
498 toastr.error(t`Connection Manager is required for /profile-genstream. Use /gen or /genraw instead.`);
499 return '';
500 }
501
502 const profileIdOrName = args?.profile;
503 const includeReasoning = isTrueBoolean(args?.reasoning);
504 const systemPrompt = typeof args?.system == 'string' ? args.system : '';
505 const maxTokens = Number(args?.length ?? 2048) || 2048;
506 const lock = isTrueBoolean(args?.lock);
507 const generatingLabel = typeof args?.generating === 'string' ? args.generating : 'Generating...';
508 const completedLabel = typeof args?.completed === 'string' ? args.completed : 'Generated';
509 const enableStop = !isFalseBoolean(args?.stop);
510 const onStopClosure = args?.onStop instanceof SlashCommandClosure ? args.onStop : null;
511 const onCompleteClosure = args?.onComplete instanceof SlashCommandClosure ? args.onComplete : null;
512
513 // Parse delay: 'infinite' or negative = null (stay open), number = delay in ms
514 let completeDelay = 3000; // Default 3 seconds
515 if (args?.delay !== undefined) {
516 if (typeof args.delay === 'string' && args.delay.toLowerCase() === 'infinite') {
517 completeDelay = null; // Stay until user closes
518 } else {
519 const parsed = Number(args.delay);
520 if (!isNaN(parsed) && parsed >= 0) {
521 completeDelay = parsed;
522 } else if (!isNaN(parsed) && parsed < 0) {
523 completeDelay = null; // Negative = infinite
524 }
525 }
526 }
527
528 // Create abort controller for stop functionality (when stop is enabled)
529 const abortController = enableStop ? new AbortController() : null;
530
531 // Compose the stop handler: abort the request + optionally invoke user closure
532 const onStopHandler = enableStop ? async () => {
533 abortController.abort();
534 if (onStopClosure) {
535 try {
536 const localClosure = onStopClosure.getCopy();
537 localClosure.onProgress = () => { };
538 await localClosure.execute();
539 } catch (e) {
540 console.error('[GenStream] Error executing onStop closure', e);
541 }
542 }
543 } : null;
544
545 try {
546 if (lock) {
547 deactivateSendButtons();
548 }
549
550 // Determine which profile to use
551 // Use the currently selected profile if no profile specified
552 let effectiveProfileId = context.extensionSettings.connectionManager.selectedProfile;
553
554 const profiles = context.extensionSettings.connectionManager.profiles;
555
556 if (profileIdOrName) {
557 // Use try to find profile by id first, then fuse search
558 const profile = profiles.find(p => p.id === profileIdOrName);
559 if (profile) {
560 effectiveProfileId = profile.id;
561 } else {
562 const keys = [
563 { name: 'name', weight: 10 },
564 ];
565 const fuseResults = performFuzzySearch('profile', profiles, keys, profileIdOrName);
566 if (fuseResults.length > 0) {
567 effectiveProfileId = fuseResults[0].item.id;
568 } else {
569 toastr.warning(t`Connection profile not found: ${profileIdOrName}`);
570 return '';
571 }
572 }
573 }
574
575 if (!effectiveProfileId) {
576 toastr.error(t`No connection profile specified or selected. Use profile= argument or select a profile in Connection Manager.`);
577 return '';
578 }
579
580 // Create streaming display
581 const display = new StreamingDisplay();
582 display.show({
583 label: generatingLabel,
584 icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
585 onStop: onStopHandler,
586 });
587
588 const messages = [
589 ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
590 { role: 'user', content: value },
591 ];
592
593 let finalText = '';
594 let finalReasoning = '';
595
596 /** Gets the final (if requested, formatted) text to return for this command @returns {string} */
597 function buildResultText() {
598 // Format output with reasoning if requested
599 if (includeReasoning && finalReasoning) {
600 const { formatted } = formatReasoning(finalReasoning, finalText);
601 return formatted;
602 }
603
604 return finalText;
605 }
606
607 try {
608 // Attempt streaming first
609 const streamResponse = await ConnectionManagerRequestService.sendRequest(
610 effectiveProfileId,
611 messages,
612 maxTokens,
613 { extractData: true, includePreset: true, stream: true, signal: abortController?.signal ?? undefined },
614 );
615
616 if (typeof streamResponse === 'function') {
617 const generator = streamResponse();
618 for await (const chunk of generator) {
619 finalText = chunk.text;
620 finalReasoning = chunk.state?.reasoning || '';
621 display.updateReasoning(finalReasoning);
622 display.updateContent(finalText);
623 }
624 } else {
625 // Non-streaming fallback within the try block
626 const extracted = streamResponse;
627 finalText = extracted?.content || '';
628 finalReasoning = extracted?.reasoning || '';
629 if (finalReasoning) {
630 display.updateReasoning(finalReasoning);
631 }
632 display.updateContent(finalText);
633 }
634 } catch (error) {
635 // If the user clicked stop, don't retry — show stopped state and return empty
636 if (abortController?.signal?.aborted) {
637 display.markStopped({ label: `${generatingLabel} [Stopped]` });
638 return buildResultText();
639 }
640
641 console.warn('[Slash Commands] Streaming failed, falling back to non-streaming:', error);
642 display.hide({ instant: true });
643
644 // Retry with non-streaming
645 const response = await ConnectionManagerRequestService.sendRequest(
646 effectiveProfileId,
647 messages,
648 maxTokens,
649 { extractData: true, includePreset: true, stream: false },
650 );
651
652 const extracted = /** @type {import('../../custom-request.js').ExtractedData} */ (response);
653 finalText = extracted?.content || '';
654 finalReasoning = extracted?.reasoning || '';
655
656 // Show quick non-streaming display
657 display.show({
658 label: generatingLabel,
659 icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
660 });
661 if (finalReasoning) {
662 display.updateReasoning(finalReasoning);
663 }
664 display.updateContent(finalText);
665 }
666
667 // Mark as complete with delay (null = stay open until user closes)
668 display.complete({ label: completedLabel, delay: completeDelay });
669
670 // Invoke onComplete closure if provided
671 if (onCompleteClosure) {
672 try {
673 const localClosure = onCompleteClosure.getCopy();
674 localClosure.onProgress = () => { };
675 await localClosure.execute();
676 } catch (e) {
677 console.error('[GenStream] Error executing onComplete closure', e);
678 }
679 }
680
681 if (!finalText) {
682 toastr.warning(t`Generation returned empty result`);
683 return '';
684 }
685
686 return buildResultText();
687 } catch (err) {
688 console.error('Error on /genstream generation', err);
689 toastr.error(err.message, t`API Error`, { preventDuplicates: true });
690 return '';
691 } finally {
692 if (lock) {
693 activateSendButtons();
694 }
695 }
696}
697
698/**
699 * Adds a quick-switch connection profile button to the bottom-left of the chat bar.
700 * Clicking it opens a small popup listing all saved connection profiles; clicking a
701 * profile switches to it by driving the Connection Manager's #connection_profiles select.
702 */
703function addQuickSwitchButton() {
704 const leftSendForm = document.getElementById('leftSendForm');
705 if (!leftSendForm) {
706 console.warn('[Connection Manager] #leftSendForm not found, skipping quick-switch button');
707 return;
708 }
709
710 // Avoid adding the button twice (e.g. on extension re-activation)
711 if (document.getElementById('connection_profile_switcher')) {
712 return;
713 }
714
715 const button = document.createElement('div');
716 button.id = 'connection_profile_switcher';
717 button.className = 'fa-solid fa-plug interactable';
718 button.tabIndex = 0;
719 button.title = t`Quick-switch connection profile`;
720 button.setAttribute('data-i18n', '[title]Quick-switch connection profile');
721 leftSendForm.appendChild(button);
722
723 const menu = document.createElement('div');
724 menu.id = 'connection_profile_switcher_menu';
725 menu.style.display = 'none';
726 const list = document.createElement('ul');
727 list.id = 'connection_profile_switcher_list';
728 list.className = 'list-group';
729 menu.appendChild(list);
730 document.body.appendChild(menu);
731
732 const $menu = $(menu);
733 const popper = Popper.createPopper(button, menu, {
734 placement: 'top-start',
735 });
736
737 /**
738 * Rebuilds the list of profiles from the current settings.
739 */
740 function rebuildList() {
741 list.innerHTML = '';
742 const profiles = extension_settings.connectionManager.profiles;
743 const selectedProfile = extension_settings.connectionManager.selectedProfile;
744
745 if (!Array.isArray(profiles) || profiles.length === 0) {
746 const emptyItem = document.createElement('li');
747 emptyItem.className = 'list-group-item disabled';
748 emptyItem.textContent = t`No connection profiles saved`;
749 emptyItem.setAttribute('data-i18n', 'No connection profiles saved');
750 list.appendChild(emptyItem);
751 return;
752 }
753
754 const sortedProfiles = profiles.slice().sort((a, b) => a.name.localeCompare(b.name));
755 for (const profile of sortedProfiles) {
756 const item = document.createElement('li');
757 item.className = 'list-group-item interactable';
758 item.dataset.profileId = profile.id;
759 if (profile.id === selectedProfile) {
760 item.classList.add('selected');
761 }
762 const icon = document.createElement('i');
763 icon.className = profile.id === selectedProfile ? 'fa-fw fa-solid fa-check' : 'fa-fw fa-solid';
764 const label = document.createElement('span');
765 label.textContent = profile.name;
766 item.appendChild(icon);
767 item.appendChild(label);
768 list.appendChild(item);
769 }
770 }
771
772 button.addEventListener('click', (e) => {
773 e.preventDefault();
774 if ($menu.is(':visible')) {
775 $menu.fadeOut(animation_duration);
776 return;
777 }
778 rebuildList();
779 $menu.fadeIn(animation_duration);
780 popper.update();
781 });
782
783 // Switch profile when a list item is clicked (event delegation).
784 list.addEventListener('click', (e) => {
785 const item = e.target instanceof Element ? e.target.closest('li[data-profile-id]') : null;
786 if (!item) {
787 return;
788 }
789 const profileId = item.dataset.profileId;
790 $menu.fadeOut(animation_duration);
791 if (!profileId) {
792 return;
793 }
794 /** @type {HTMLSelectElement} */
795 // @ts-ignore
796 const select = document.getElementById('connection_profiles');
797 if (!select) {
798 console.warn('[Connection Manager] #connection_profiles select not found');
799 return;
800 }
801 if (!Array.from(select.options).some(o => o.value === profileId)) {
802 console.warn(`[Connection Manager] Profile option not found in select: ${profileId}`);
803 return;
804 }
805 select.value = profileId;
806 select.dispatchEvent(new Event('change'));
807 });
808
809 // Close the menu when clicking outside of it or the button.
810 document.addEventListener('click', (e) => {
811 if (!$menu.is(':visible')) {
812 return;
813 }
814 const target = e.target;
815 if (target instanceof Node && (menu.contains(target) || button.contains(target) || button === target)) {
816 return;
817 }
818 $menu.fadeOut(animation_duration);
819 });
820
821 // Keep the highlighted item up to date while the menu is open.
822 eventSource.on(event_types.CONNECTION_PROFILE_LOADED, () => {
823 if ($menu.is(':visible')) {
824 rebuildList();
825 popper.update();
826 }
827 });
828}
829
830export async function init() {
831 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
832
833 for (const key of Object.keys(DEFAULT_SETTINGS)) {
834 if (extension_settings.connectionManager[key] === undefined) {
835 extension_settings.connectionManager[key] = DEFAULT_SETTINGS[key];
836 }
837 }
838
839 const container = document.getElementById('rm_api_block');
840 const settings = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
841 container.insertAdjacentHTML('afterbegin', settings);
842
843 /** @type {HTMLSelectElement} */
844 // @ts-ignore
845 const profiles = document.getElementById('connection_profiles');
846 renderConnectionProfiles(profiles);
847
848 addQuickSwitchButton();
849
850 function toggleProfileSpecificButtons() {
851 const profileId = extension_settings.connectionManager.selectedProfile;
852 const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];
853 profileSpecificButtons.forEach(id => document.getElementById(id).classList.toggle('disabled', !profileId));
854 }
855 toggleProfileSpecificButtons();
856
857 profiles.addEventListener('change', async function () {
858 const selectedProfile = profiles.selectedOptions[0];
859 if (!selectedProfile) {
860 // Safety net for preventing the command getting stuck
861 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
862 return;
863 }
864
865 const profileId = selectedProfile.value;
866 extension_settings.connectionManager.selectedProfile = profileId;
867 saveSettingsDebounced();
868 await renderDetailsContent(detailsContent);
869
870 toggleProfileSpecificButtons();
871
872 // None option selected
873 if (!profileId) {
874 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
875 return;
876 }
877
878 const profile = extension_settings.connectionManager.profiles.find(p => p.id === profileId);
879
880 if (!profile) {
881 console.log(`Profile not found: ${profileId}`);
882 return;
883 }
884
885 await applyConnectionProfile(profile);
886 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
887 });
888
889 const reloadButton = document.getElementById('reload_connection_profile');
890 reloadButton.addEventListener('click', async () => {
891 const selectedProfile = extension_settings.connectionManager.selectedProfile;
892 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
893 if (!profile) {
894 console.log('No profile selected');
895 return;
896 }
897 await applyConnectionProfile(profile);
898 await renderDetailsContent(detailsContent);
899 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
900 toastr.success('Connection profile reloaded', '', { timeOut: 1500 });
901 });
902
903 const createButton = document.getElementById('create_connection_profile');
904 createButton.addEventListener('click', async () => {
905 const profile = await createConnectionProfile();
906 if (!profile) {
907 return;
908 }
909 extension_settings.connectionManager.profiles.push(profile);
910 extension_settings.connectionManager.selectedProfile = profile.id;
911 saveSettingsDebounced();
912 renderConnectionProfiles(profiles);
913 await renderDetailsContent(detailsContent);
914 await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
915 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
916 });
917
918 const updateButton = document.getElementById('update_connection_profile');
919 updateButton.addEventListener('click', async () => {
920 const selectedProfile = extension_settings.connectionManager.selectedProfile;
921 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
922 if (!profile) {
923 console.log('No profile selected');
924 return;
925 }
926 const oldProfile = structuredClone(profile);
927 await updateConnectionProfile(profile);
928 await renderDetailsContent(detailsContent);
929 saveSettingsDebounced();
930 await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
931 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
932 toastr.success('Connection profile updated', '', { timeOut: 1500 });
933 });
934
935 const deleteButton = document.getElementById('delete_connection_profile');
936 deleteButton.addEventListener('click', async () => {
937 await deleteConnectionProfile();
938 renderConnectionProfiles(profiles);
939 await renderDetailsContent(detailsContent);
940 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
941 });
942
943 const editButton = document.getElementById('edit_connection_profile');
944 editButton.addEventListener('click', async () => {
945 const selectedProfile = extension_settings.connectionManager.selectedProfile;
946 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
947 if (!profile) {
948 console.log('No profile selected');
949 return;
950 }
951 if (!Array.isArray(profile.exclude)) {
952 profile.exclude = [];
953 }
954
955 let saveChanges = false;
956 const sortByViewOrder = (a, b) => Object.keys(FANCY_NAMES).indexOf(a) - Object.keys(FANCY_NAMES).indexOf(b);
957 const commands = profile.mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
958 const settings = commands.slice().sort(sortByViewOrder).reduce((acc, command) => {
959 const fancyName = FANCY_NAMES[command];
960 acc[fancyName] = !profile.exclude.includes(command);
961 return acc;
962 }, {});
963 const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'edit', { name: profile.name, settings }));
964 let newName = await callGenericPopup(template, POPUP_TYPE.INPUT, profile.name, {
965 customButtons: [{
966 text: t`Save and Update`,
967 classes: ['popup-button-ok'],
968 result: POPUP_RESULT.AFFIRMATIVE,
969 action: () => {
970 saveChanges = true;
971 },
972 }],
973 });
974
975 // If it's cancelled, it will be false
976 if (!newName) {
977 return;
978 }
979 newName = DOMPurify.sanitize(String(newName));
980 if (!newName) {
981 toastr.error('Name cannot be empty.');
982 return;
983 }
984
985 if (profile.name !== newName && extension_settings.connectionManager.profiles.some(p => p.name === newName)) {
986 toastr.error('A profile with the same name already exists.');
987 return;
988 }
989
990 const newExcludeList = template.find('input[name="exclude"]:not(:checked)').map(function () {
991 return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
992 }).get();
993
994 const oldProfile = structuredClone(profile);
995 if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
996 profile.exclude = newExcludeList;
997 for (const command of newExcludeList) {
998 delete profile[command];
999 }
1000 if (saveChanges) {
1001 await updateConnectionProfile(profile);
1002 } else {
1003 toastr.info('Press "Update" to record them into the profile.', 'Included settings list updated');
1004 }
1005 }
1006
1007 if (profile.name !== newName) {
1008 toastr.success('Connection profile renamed.');
1009 profile.name = newName;
1010 }
1011
1012 saveSettingsDebounced();
1013 await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
1014 renderConnectionProfiles(profiles);
1015 await renderDetailsContent(detailsContent);
1016 });
1017
1018 /** @type {HTMLElement} */
1019 const viewDetails = document.getElementById('view_connection_profile');
1020 const detailsContent = document.getElementById('connection_profile_details_content');
1021 viewDetails.addEventListener('click', async () => {
1022 viewDetails.classList.toggle('active');
1023 detailsContent.classList.toggle('hidden');
1024 await renderDetailsContent(detailsContent);
1025 });
1026
1027 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1028 name: 'profile',
1029 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.',
1030 returns: 'name of the profile',
1031 unnamedArgumentList: [
1032 SlashCommandArgument.fromProps({
1033 description: 'Name of the connection profile',
1034 enumProvider: profilesProvider,
1035 isRequired: false,
1036 }),
1037 ],
1038 namedArgumentList: [
1039 SlashCommandNamedArgument.fromProps({
1040 name: 'await',
1041 description: 'Wait for the connection profile to be applied before returning.',
1042 isRequired: false,
1043 typeList: [ARGUMENT_TYPE.BOOLEAN],
1044 defaultValue: 'true',
1045 enumList: commonEnumProviders.boolean('trueFalse')(),
1046 }),
1047 SlashCommandNamedArgument.fromProps({
1048 name: 'timeout',
1049 description: 'Maximum time to wait for the API connection to be established, in milliseconds. Set to 0 to disable. Only applies when await=true.',
1050 isRequired: false,
1051 typeList: [ARGUMENT_TYPE.NUMBER],
1052 defaultValue: '2000',
1053 }),
1054 ],
1055 callback: async (args, value) => {
1056 if (!value || typeof value !== 'string') {
1057 const selectedProfile = extension_settings.connectionManager.selectedProfile;
1058 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
1059 if (!profile) {
1060 return NONE;
1061 }
1062 return profile.name;
1063 }
1064
1065 if (value === NONE) {
1066 profiles.selectedIndex = 0;
1067 profiles.dispatchEvent(new Event('change'));
1068 return NONE;
1069 }
1070
1071 const profile = findProfileByName(value);
1072
1073 if (!profile) {
1074 return '';
1075 }
1076
1077 const shouldAwait = !isFalseBoolean(String(args?.await));
1078 const awaitPromise = new Promise((resolve) => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
1079
1080 profiles.selectedIndex = Array.from(profiles.options).findIndex(o => o.value === profile.id);
1081 profiles.dispatchEvent(new Event('change'));
1082
1083 if (shouldAwait) {
1084 await awaitPromise;
1085
1086 // We should also await the connection to be established
1087 const parsedTimeout = parseInt(args?.timeout?.toString());
1088 const timeout = !isNaN(parsedTimeout) ? Math.max(0, parsedTimeout) : 2000;
1089 if (timeout > 0) {
1090 await waitUntilCondition(() => online_status !== 'no_connection', timeout, 100, { rejectOnTimeout: false });
1091 }
1092 }
1093
1094 return profile.name;
1095 },
1096 }));
1097
1098 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1099 name: 'profile-list',
1100 helpString: 'List all connection profile names.',
1101 returns: 'list of profile names',
1102 callback: () => JSON.stringify(extension_settings.connectionManager.profiles.map(p => p.name)),
1103 }));
1104
1105 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1106 name: 'profile-create',
1107 returns: 'name of the new profile',
1108 helpString: 'Create a new connection profile using the current settings.',
1109 unnamedArgumentList: [
1110 SlashCommandArgument.fromProps({
1111 description: 'name of the new connection profile',
1112 isRequired: true,
1113 typeList: [ARGUMENT_TYPE.STRING],
1114 }),
1115 ],
1116 callback: async (_args, name) => {
1117 if (!name || typeof name !== 'string') {
1118 toastr.warning('Please provide a name for the new connection profile.');
1119 return '';
1120 }
1121 const profile = await createConnectionProfile(name);
1122 if (!profile) {
1123 return '';
1124 }
1125 extension_settings.connectionManager.profiles.push(profile);
1126 extension_settings.connectionManager.selectedProfile = profile.id;
1127 saveSettingsDebounced();
1128 renderConnectionProfiles(profiles);
1129 await renderDetailsContent(detailsContent);
1130 await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
1131 return profile.name;
1132 },
1133 }));
1134
1135 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1136 name: 'profile-update',
1137 helpString: 'Update the selected connection profile.',
1138 callback: async () => {
1139 const selectedProfile = extension_settings.connectionManager.selectedProfile;
1140 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
1141 if (!profile) {
1142 toastr.warning('No profile selected.');
1143 return '';
1144 }
1145 const oldProfile = structuredClone(profile);
1146 await updateConnectionProfile(profile);
1147 await renderDetailsContent(detailsContent);
1148 saveSettingsDebounced();
1149 await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
1150 return profile.name;
1151 },
1152 }));
1153
1154 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1155 name: 'profile-get',
1156 helpString: 'Get the details of the connection profile. Returns the selected profile if no argument is provided.',
1157 returns: 'object of the selected profile',
1158 unnamedArgumentList: [
1159 SlashCommandArgument.fromProps({
1160 description: 'Name of the connection profile',
1161 enumProvider: profilesProvider,
1162 isRequired: false,
1163 }),
1164 ],
1165 callback: async (_args, value) => {
1166 if (!value || typeof value !== 'string') {
1167 const selectedProfile = extension_settings.connectionManager.selectedProfile;
1168 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
1169 if (!profile) {
1170 return '';
1171 }
1172 return JSON.stringify(profile);
1173 }
1174
1175 const profile = findProfileByName(value);
1176 if (!profile) {
1177 return '';
1178 }
1179 return JSON.stringify(profile);
1180 },
1181 }));
1182
1183 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1184 name: 'profile-genstream',
1185 callback: generateStreamCallback,
1186 returns: t`generated text`,
1187 namedArgumentList: [
1188 new SlashCommandNamedArgument(
1189 'lock', t`lock user input during generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(),
1190 ),
1191 SlashCommandNamedArgument.fromProps({
1192 name: 'profile',
1193 description: t`connection profile ID to use for generation`,
1194 typeList: [ARGUMENT_TYPE.STRING],
1195 enumProvider: commonEnumProviders.connectionProfiles(),
1196 }),
1197 SlashCommandNamedArgument.fromProps({
1198 name: 'reasoning',
1199 description: t`include formatted reasoning in the output`,
1200 typeList: [ARGUMENT_TYPE.BOOLEAN],
1201 defaultValue: 'false',
1202 enumProvider: commonEnumProviders.boolean('trueFalse'),
1203 }),
1204 SlashCommandNamedArgument.fromProps({
1205 name: 'system',
1206 description: t`system prompt at the start`,
1207 typeList: [ARGUMENT_TYPE.STRING],
1208 }),
1209 SlashCommandNamedArgument.fromProps({
1210 name: 'length',
1211 description: t`API response length in tokens`,
1212 typeList: [ARGUMENT_TYPE.NUMBER],
1213 defaultValue: '2048',
1214 }),
1215 SlashCommandNamedArgument.fromProps({
1216 name: 'generating',
1217 description: t`label/title for the generation display`,
1218 typeList: [ARGUMENT_TYPE.STRING],
1219 defaultValue: 'Generating...',
1220 }),
1221 SlashCommandNamedArgument.fromProps({
1222 name: 'completed',
1223 description: t`updated label/title for when generation completes`,
1224 typeList: [ARGUMENT_TYPE.STRING],
1225 defaultValue: 'Generated',
1226 }),
1227 SlashCommandNamedArgument.fromProps({
1228 name: 'delay',
1229 description: t`auto-hide delay in ms after generation completes. Use "infinite" or negative to keep until manually closed`,
1230 typeList: [ARGUMENT_TYPE.NUMBER],
1231 defaultValue: '3000',
1232 enumList: [
1233 new SlashCommandEnumValue('infinite', 'Keep the streaming display open until manually closed', 'command', '♾️'),
1234 new SlashCommandEnumValue('any delay in seconds', null, 'number', '⌚', () => true, input => input),
1235 ],
1236 }),
1237 SlashCommandNamedArgument.fromProps({
1238 name: 'stop',
1239 description: t`show a stop button on the streaming display that aborts generation when clicked`,
1240 typeList: [ARGUMENT_TYPE.BOOLEAN],
1241 defaultValue: 'true',
1242 enumProvider: commonEnumProviders.boolean('trueFalse'),
1243 }),
1244 SlashCommandNamedArgument.fromProps({
1245 name: 'onStop',
1246 description: t`closure to execute when the stop button is clicked (in addition to aborting the request)`,
1247 typeList: [ARGUMENT_TYPE.CLOSURE],
1248 }),
1249 SlashCommandNamedArgument.fromProps({
1250 name: 'onComplete',
1251 description: t`closure to execute after generation completes successfully`,
1252 typeList: [ARGUMENT_TYPE.CLOSURE],
1253 }),
1254 ],
1255 unnamedArgumentList: [
1256 SlashCommandArgument.fromProps({
1257 description: 'prompt',
1258 typeList: [ARGUMENT_TYPE.STRING],
1259 isRequired: true,
1260 }),
1261 ],
1262 helpString: `
1263 <div>
1264 ${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`}
1265 </div>
1266 <div>
1267 ${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`}
1268 </div>
1269 <div>
1270 ${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`}
1271 </div>
1272 <div>
1273 ${t`Use delay to control auto-hide behavior: number (ms), "infinite", or negative to keep the display open until manually closed. The display shows a green LED when complete.`}
1274 </div>
1275 <div>
1276 ${t`A stop button is shown by default (stop=true). Click it to abort generation and return whatever was streamed so far. Use stop=false to hide the stop button.`}
1277 </div>
1278 <div>
1279 ${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`}
1280 </div>
1281 <div>
1282 ${t`Example: <pre><code>/profile-genstream profile=my-profile-id reasoning=true Summarize the following text</code></pre>`}
1283 </div>
1284 <div>
1285 ${t`Example with infinite display: <pre><code>/profile-genstream delay=infinite Tell me a story</code></pre>`}
1286 </div>
1287 <div>
1288 ${t`Example with custom stop handler: <pre><code>/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story</code></pre>`}
1289 </div>
1290 `,
1291 }));
1292}