Blame Raw
permissionBRICK · 8c010f64 · · 1158 lines (44.3 KB)
3 contributors
1import { DOMPurify, Fuse } from '../../../lib.js';
2
3import { activateSendButtons, 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
698export async function init() {
699 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
700
701 for (const key of Object.keys(DEFAULT_SETTINGS)) {
702 if (extension_settings.connectionManager[key] === undefined) {
703 extension_settings.connectionManager[key] = DEFAULT_SETTINGS[key];
704 }
705 }
706
707 const container = document.getElementById('rm_api_block');
708 const settings = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
709 container.insertAdjacentHTML('afterbegin', settings);
710
711 /** @type {HTMLSelectElement} */
712 // @ts-ignore
713 const profiles = document.getElementById('connection_profiles');
714 renderConnectionProfiles(profiles);
715
716 function toggleProfileSpecificButtons() {
717 const profileId = extension_settings.connectionManager.selectedProfile;
718 const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];
719 profileSpecificButtons.forEach(id => document.getElementById(id).classList.toggle('disabled', !profileId));
720 }
721 toggleProfileSpecificButtons();
722
723 profiles.addEventListener('change', async function () {
724 const selectedProfile = profiles.selectedOptions[0];
725 if (!selectedProfile) {
726 // Safety net for preventing the command getting stuck
727 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
728 return;
729 }
730
731 const profileId = selectedProfile.value;
732 extension_settings.connectionManager.selectedProfile = profileId;
733 saveSettingsDebounced();
734 await renderDetailsContent(detailsContent);
735
736 toggleProfileSpecificButtons();
737
738 // None option selected
739 if (!profileId) {
740 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
741 return;
742 }
743
744 const profile = extension_settings.connectionManager.profiles.find(p => p.id === profileId);
745
746 if (!profile) {
747 console.log(`Profile not found: ${profileId}`);
748 return;
749 }
750
751 await applyConnectionProfile(profile);
752 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
753 });
754
755 const reloadButton = document.getElementById('reload_connection_profile');
756 reloadButton.addEventListener('click', async () => {
757 const selectedProfile = extension_settings.connectionManager.selectedProfile;
758 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
759 if (!profile) {
760 console.log('No profile selected');
761 return;
762 }
763 await applyConnectionProfile(profile);
764 await renderDetailsContent(detailsContent);
765 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
766 toastr.success('Connection profile reloaded', '', { timeOut: 1500 });
767 });
768
769 const createButton = document.getElementById('create_connection_profile');
770 createButton.addEventListener('click', async () => {
771 const profile = await createConnectionProfile();
772 if (!profile) {
773 return;
774 }
775 extension_settings.connectionManager.profiles.push(profile);
776 extension_settings.connectionManager.selectedProfile = profile.id;
777 saveSettingsDebounced();
778 renderConnectionProfiles(profiles);
779 await renderDetailsContent(detailsContent);
780 await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
781 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
782 });
783
784 const updateButton = document.getElementById('update_connection_profile');
785 updateButton.addEventListener('click', async () => {
786 const selectedProfile = extension_settings.connectionManager.selectedProfile;
787 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
788 if (!profile) {
789 console.log('No profile selected');
790 return;
791 }
792 const oldProfile = structuredClone(profile);
793 await updateConnectionProfile(profile);
794 await renderDetailsContent(detailsContent);
795 saveSettingsDebounced();
796 await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
797 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
798 toastr.success('Connection profile updated', '', { timeOut: 1500 });
799 });
800
801 const deleteButton = document.getElementById('delete_connection_profile');
802 deleteButton.addEventListener('click', async () => {
803 await deleteConnectionProfile();
804 renderConnectionProfiles(profiles);
805 await renderDetailsContent(detailsContent);
806 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
807 });
808
809 const editButton = document.getElementById('edit_connection_profile');
810 editButton.addEventListener('click', async () => {
811 const selectedProfile = extension_settings.connectionManager.selectedProfile;
812 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
813 if (!profile) {
814 console.log('No profile selected');
815 return;
816 }
817 if (!Array.isArray(profile.exclude)) {
818 profile.exclude = [];
819 }
820
821 let saveChanges = false;
822 const sortByViewOrder = (a, b) => Object.keys(FANCY_NAMES).indexOf(a) - Object.keys(FANCY_NAMES).indexOf(b);
823 const commands = profile.mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
824 const settings = commands.slice().sort(sortByViewOrder).reduce((acc, command) => {
825 const fancyName = FANCY_NAMES[command];
826 acc[fancyName] = !profile.exclude.includes(command);
827 return acc;
828 }, {});
829 const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'edit', { name: profile.name, settings }));
830 let newName = await callGenericPopup(template, POPUP_TYPE.INPUT, profile.name, {
831 customButtons: [{
832 text: t`Save and Update`,
833 classes: ['popup-button-ok'],
834 result: POPUP_RESULT.AFFIRMATIVE,
835 action: () => {
836 saveChanges = true;
837 },
838 }],
839 });
840
841 // If it's cancelled, it will be false
842 if (!newName) {
843 return;
844 }
845 newName = DOMPurify.sanitize(String(newName));
846 if (!newName) {
847 toastr.error('Name cannot be empty.');
848 return;
849 }
850
851 if (profile.name !== newName && extension_settings.connectionManager.profiles.some(p => p.name === newName)) {
852 toastr.error('A profile with the same name already exists.');
853 return;
854 }
855
856 const newExcludeList = template.find('input[name="exclude"]:not(:checked)').map(function () {
857 return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
858 }).get();
859
860 const oldProfile = structuredClone(profile);
861 if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
862 profile.exclude = newExcludeList;
863 for (const command of newExcludeList) {
864 delete profile[command];
865 }
866 if (saveChanges) {
867 await updateConnectionProfile(profile);
868 } else {
869 toastr.info('Press "Update" to record them into the profile.', 'Included settings list updated');
870 }
871 }
872
873 if (profile.name !== newName) {
874 toastr.success('Connection profile renamed.');
875 profile.name = newName;
876 }
877
878 saveSettingsDebounced();
879 await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
880 renderConnectionProfiles(profiles);
881 await renderDetailsContent(detailsContent);
882 });
883
884 /** @type {HTMLElement} */
885 const viewDetails = document.getElementById('view_connection_profile');
886 const detailsContent = document.getElementById('connection_profile_details_content');
887 viewDetails.addEventListener('click', async () => {
888 viewDetails.classList.toggle('active');
889 detailsContent.classList.toggle('hidden');
890 await renderDetailsContent(detailsContent);
891 });
892
893 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
894 name: 'profile',
895 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.',
896 returns: 'name of the profile',
897 unnamedArgumentList: [
898 SlashCommandArgument.fromProps({
899 description: 'Name of the connection profile',
900 enumProvider: profilesProvider,
901 isRequired: false,
902 }),
903 ],
904 namedArgumentList: [
905 SlashCommandNamedArgument.fromProps({
906 name: 'await',
907 description: 'Wait for the connection profile to be applied before returning.',
908 isRequired: false,
909 typeList: [ARGUMENT_TYPE.BOOLEAN],
910 defaultValue: 'true',
911 enumList: commonEnumProviders.boolean('trueFalse')(),
912 }),
913 SlashCommandNamedArgument.fromProps({
914 name: 'timeout',
915 description: 'Maximum time to wait for the API connection to be established, in milliseconds. Set to 0 to disable. Only applies when await=true.',
916 isRequired: false,
917 typeList: [ARGUMENT_TYPE.NUMBER],
918 defaultValue: '2000',
919 }),
920 ],
921 callback: async (args, value) => {
922 if (!value || typeof value !== 'string') {
923 const selectedProfile = extension_settings.connectionManager.selectedProfile;
924 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
925 if (!profile) {
926 return NONE;
927 }
928 return profile.name;
929 }
930
931 if (value === NONE) {
932 profiles.selectedIndex = 0;
933 profiles.dispatchEvent(new Event('change'));
934 return NONE;
935 }
936
937 const profile = findProfileByName(value);
938
939 if (!profile) {
940 return '';
941 }
942
943 const shouldAwait = !isFalseBoolean(String(args?.await));
944 const awaitPromise = new Promise((resolve) => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
945
946 profiles.selectedIndex = Array.from(profiles.options).findIndex(o => o.value === profile.id);
947 profiles.dispatchEvent(new Event('change'));
948
949 if (shouldAwait) {
950 await awaitPromise;
951
952 // We should also await the connection to be established
953 const parsedTimeout = parseInt(args?.timeout?.toString());
954 const timeout = !isNaN(parsedTimeout) ? Math.max(0, parsedTimeout) : 2000;
955 if (timeout > 0) {
956 await waitUntilCondition(() => online_status !== 'no_connection', timeout, 100, { rejectOnTimeout: false });
957 }
958 }
959
960 return profile.name;
961 },
962 }));
963
964 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
965 name: 'profile-list',
966 helpString: 'List all connection profile names.',
967 returns: 'list of profile names',
968 callback: () => JSON.stringify(extension_settings.connectionManager.profiles.map(p => p.name)),
969 }));
970
971 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
972 name: 'profile-create',
973 returns: 'name of the new profile',
974 helpString: 'Create a new connection profile using the current settings.',
975 unnamedArgumentList: [
976 SlashCommandArgument.fromProps({
977 description: 'name of the new connection profile',
978 isRequired: true,
979 typeList: [ARGUMENT_TYPE.STRING],
980 }),
981 ],
982 callback: async (_args, name) => {
983 if (!name || typeof name !== 'string') {
984 toastr.warning('Please provide a name for the new connection profile.');
985 return '';
986 }
987 const profile = await createConnectionProfile(name);
988 if (!profile) {
989 return '';
990 }
991 extension_settings.connectionManager.profiles.push(profile);
992 extension_settings.connectionManager.selectedProfile = profile.id;
993 saveSettingsDebounced();
994 renderConnectionProfiles(profiles);
995 await renderDetailsContent(detailsContent);
996 await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
997 return profile.name;
998 },
999 }));
1000
1001 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1002 name: 'profile-update',
1003 helpString: 'Update the selected connection profile.',
1004 callback: async () => {
1005 const selectedProfile = extension_settings.connectionManager.selectedProfile;
1006 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
1007 if (!profile) {
1008 toastr.warning('No profile selected.');
1009 return '';
1010 }
1011 const oldProfile = structuredClone(profile);
1012 await updateConnectionProfile(profile);
1013 await renderDetailsContent(detailsContent);
1014 saveSettingsDebounced();
1015 await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
1016 return profile.name;
1017 },
1018 }));
1019
1020 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1021 name: 'profile-get',
1022 helpString: 'Get the details of the connection profile. Returns the selected profile if no argument is provided.',
1023 returns: 'object of the selected profile',
1024 unnamedArgumentList: [
1025 SlashCommandArgument.fromProps({
1026 description: 'Name of the connection profile',
1027 enumProvider: profilesProvider,
1028 isRequired: false,
1029 }),
1030 ],
1031 callback: async (_args, value) => {
1032 if (!value || typeof value !== 'string') {
1033 const selectedProfile = extension_settings.connectionManager.selectedProfile;
1034 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
1035 if (!profile) {
1036 return '';
1037 }
1038 return JSON.stringify(profile);
1039 }
1040
1041 const profile = findProfileByName(value);
1042 if (!profile) {
1043 return '';
1044 }
1045 return JSON.stringify(profile);
1046 },
1047 }));
1048
1049 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1050 name: 'profile-genstream',
1051 callback: generateStreamCallback,
1052 returns: t`generated text`,
1053 namedArgumentList: [
1054 new SlashCommandNamedArgument(
1055 'lock', t`lock user input during generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(),
1056 ),
1057 SlashCommandNamedArgument.fromProps({
1058 name: 'profile',
1059 description: t`connection profile ID to use for generation`,
1060 typeList: [ARGUMENT_TYPE.STRING],
1061 enumProvider: commonEnumProviders.connectionProfiles(),
1062 }),
1063 SlashCommandNamedArgument.fromProps({
1064 name: 'reasoning',
1065 description: t`include formatted reasoning in the output`,
1066 typeList: [ARGUMENT_TYPE.BOOLEAN],
1067 defaultValue: 'false',
1068 enumProvider: commonEnumProviders.boolean('trueFalse'),
1069 }),
1070 SlashCommandNamedArgument.fromProps({
1071 name: 'system',
1072 description: t`system prompt at the start`,
1073 typeList: [ARGUMENT_TYPE.STRING],
1074 }),
1075 SlashCommandNamedArgument.fromProps({
1076 name: 'length',
1077 description: t`API response length in tokens`,
1078 typeList: [ARGUMENT_TYPE.NUMBER],
1079 defaultValue: '2048',
1080 }),
1081 SlashCommandNamedArgument.fromProps({
1082 name: 'generating',
1083 description: t`label/title for the generation display`,
1084 typeList: [ARGUMENT_TYPE.STRING],
1085 defaultValue: 'Generating...',
1086 }),
1087 SlashCommandNamedArgument.fromProps({
1088 name: 'completed',
1089 description: t`updated label/title for when generation completes`,
1090 typeList: [ARGUMENT_TYPE.STRING],
1091 defaultValue: 'Generated',
1092 }),
1093 SlashCommandNamedArgument.fromProps({
1094 name: 'delay',
1095 description: t`auto-hide delay in ms after generation completes. Use "infinite" or negative to keep until manually closed`,
1096 typeList: [ARGUMENT_TYPE.NUMBER],
1097 defaultValue: '3000',
1098 enumList: [
1099 new SlashCommandEnumValue('infinite', 'Keep the streaming display open until manually closed', 'command', '♾️'),
1100 new SlashCommandEnumValue('any delay in seconds', null, 'number', '⌚', () => true, input => input),
1101 ],
1102 }),
1103 SlashCommandNamedArgument.fromProps({
1104 name: 'stop',
1105 description: t`show a stop button on the streaming display that aborts generation when clicked`,
1106 typeList: [ARGUMENT_TYPE.BOOLEAN],
1107 defaultValue: 'true',
1108 enumProvider: commonEnumProviders.boolean('trueFalse'),
1109 }),
1110 SlashCommandNamedArgument.fromProps({
1111 name: 'onStop',
1112 description: t`closure to execute when the stop button is clicked (in addition to aborting the request)`,
1113 typeList: [ARGUMENT_TYPE.CLOSURE],
1114 }),
1115 SlashCommandNamedArgument.fromProps({
1116 name: 'onComplete',
1117 description: t`closure to execute after generation completes successfully`,
1118 typeList: [ARGUMENT_TYPE.CLOSURE],
1119 }),
1120 ],
1121 unnamedArgumentList: [
1122 SlashCommandArgument.fromProps({
1123 description: 'prompt',
1124 typeList: [ARGUMENT_TYPE.STRING],
1125 isRequired: true,
1126 }),
1127 ],
1128 helpString: `
1129 <div>
1130 ${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`}
1131 </div>
1132 <div>
1133 ${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`}
1134 </div>
1135 <div>
1136 ${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`}
1137 </div>
1138 <div>
1139 ${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.`}
1140 </div>
1141 <div>
1142 ${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.`}
1143 </div>
1144 <div>
1145 ${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`}
1146 </div>
1147 <div>
1148 ${t`Example: <pre><code>/profile-genstream profile=my-profile-id reasoning=true Summarize the following text</code></pre>`}
1149 </div>
1150 <div>
1151 ${t`Example with infinite display: <pre><code>/profile-genstream delay=infinite Tell me a story</code></pre>`}
1152 </div>
1153 <div>
1154 ${t`Example with custom stop handler: <pre><code>/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story</code></pre>`}
1155 </div>
1156 `,
1157 }));
1158}