New connection manager events, ConnectionManagerRequestService (#3603)

d42a81f97cab485a9442cf576d9a95a023c2f5c3

bmen25124 <bmen25124@gmail.com>

Signed
10 files changed, +637 -79Ignore whitespace
public/global.d.ts+4 -0
@@ -1,7 +1,11 @@
11import libs from './lib';
22import getContext from './scripts/st-context';
3+import { power_user } from './scripts/power-user';
34
45declare global {
6+ // Custom types
7+ declare type InstructSettings = typeof power_user.instruct;
8+
59 // Global namespace modules
610 interface Window {
711 ai: any;
public/script.js+14 -0
@@ -514,6 +514,9 @@ export const event_types = {
514514 ONLINE_STATUS_CHANGED: 'online_status_changed',
515515 IMAGE_SWIPED: 'image_swiped',
516516 CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
517+ CONNECTION_PROFILE_CREATED: 'connection_profile_created',
518+ CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',
519+ CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
517520 TOOL_CALLS_PERFORMED: 'tool_calls_performed',
518521 TOOL_CALLS_RENDERED: 'tool_calls_rendered',
519522};
@@ -9196,6 +9199,17 @@ function swipe_right(_event, { source, repeated } = {}) {
91969199 }
91979200}
91989201
9202+/**
9203+ * @typedef {object} ConnectAPIMap
9204+ * @property {string} selected - API name (e.g. "textgenerationwebui", "openai")
9205+ * @property {string?} [button] - CSS selector for the API button
9206+ * @property {string?} [type] - API type, mostly used by text completion. (e.g. "openrouter")
9207+ * @property {string?} [source] - API source, mostly used by chat completion. (e.g. "openai")
9208+ */
9209+
9210+/**
9211+ * @type {Record<string, ConnectAPIMap>}
9212+ */
91999213export const CONNECT_API_MAP = {
92009214 // Default APIs not contined inside text gen / chat gen
92019215 'kobold': {
public/scripts/custom-request.js+233 -35
@@ -1,20 +1,20 @@
11import { getPresetManager } from './preset-manager.js';
22import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js';
33import { getTextGenServer } from './textgen-settings.js';
4+import { extractReasoningFromData } from './reasoning.js';
5+import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types } from './instruct-mode.js';
46
57// #region Type Definitions
68/**
79 * @typedef {Object} TextCompletionRequestBase
8- * @property {string} prompt - The text prompt for completion
910 * @property {number} max_tokens - Maximum number of tokens to generate
1011 * @property {string} [model] - Optional model name
1112 * @property {string} api_type - Type of API to use
1213 * @property {string} [api_server] - Optional API server URL
1314 * @property {number} [temperature] - Optional temperature parameter
15+ * @property {number} [min_p] - Optional min_p parameter
1416 */
1517
16-/** @typedef {Record<string, any> & TextCompletionRequestBase} TextCompletionRequest */
17-
1818/**
1919 * @typedef {Object} TextCompletionPayloadBase
2020 * @property {string} prompt - The text prompt for completion
@@ -44,6 +44,13 @@ import { getTextGenServer } from './textgen-settings.js';
4444 */
4545
4646/** @typedef {Record<string, any> & ChatCompletionPayloadBase} ChatCompletionPayload */
47+
48+/**
49+ * @typedef {Object} ExtractedData
50+ * @property {string} content - Extracted content.
51+ * @property {string} reasoning - Extracted reasoning.
52+ */
53+
4754// #endregion
4855
4956/**
@@ -53,11 +60,11 @@ export class TextCompletionService {
5360 static TYPE = 'textgenerationwebui';
5461
5562 /**
56- * @param {TextCompletionRequest} custom
63+ * @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom
5764 * @returns {TextCompletionPayload}
5865 */
5966 static createRequestData({ prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {
6067 returnconst payload = {
6168 ...props,
6269 prompt,
6370 max_tokens,
@@ -66,15 +73,25 @@ export class TextCompletionService {
6673 api_type,
6774 api_server: api_server ?? getTextGenServer(api_type),
6875 temperature,
76+ min_p,
6977 stream: false,
7078 };
79+
80+ // Remove undefined values to avoid API errors
81+ Object.keys(payload).forEach(key => {
82+ if (payload[key] === undefined) {
83+ delete payload[key];
84+ }
85+ });
86+
87+ return payload;
7188 }
7289
7390 /**
7491 * Sends a text completion request to the specified server
7592 * @param {TextCompletionPayload} data Request data
7693 * @param {boolean?} extractData Extract message from the response. Default true
7794 * @returns {Promise<stringExtractedData | any>} Extracted data or the raw response
7895 * @throws {Error}
7996 */
8097 static async sendRequest(data, extractData = true) {
@@ -91,31 +108,150 @@ export class TextCompletionService {
91108 throw json;
92109 }
93110
94- return extractData ? extractMessageFromData(json, this.TYPE) : json;
111+ if (!extractData) {
112+ return json;
113+ }
114+
115+ return {
116+ content: extractMessageFromData(json, this.TYPE),
117+ reasoning: extractReasoningFromData(json, {
118+ mainApi: this.TYPE,
119+ textGenType: data.api_type,
120+ ignoreShowThoughts: true,
121+ }),
122+ };
95123 }
96124
97125 /**
98- * @param {string} presetName
126+ * Process and send a text completion request with optional preset & instruct
99- * @param {TextCompletionRequest} custom
127+ * @param {Record<string, any> & TextCompletionRequestBase & {prompt: (ChatCompletionMessage & {ignoreInstruct?: boolean})[] |string}} custom
100128 * @param {boolean?Object} extractData Extract message from theoptions response.- DefaultConfiguration trueoptions
101- * @returns {Promise<string | any>} Extracted data or the raw response
129+ * @param {string?} [options.presetName] - Name of the preset to use for generation settings
130+ * @param {string?} [options.instructName] - Name of instruct preset for message formatting
131+ * @param {boolean} extractData - Whether to extract structured data from response
132+ * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
102133 * @throws {Error}
103134 */
104- static async sendRequestWithPreset(presetName, custom, extractData = true) {
135+ static async processRequest(
105- const presetManager = getPresetManager(this.TYPE);
136+ custom,
106- if (!presetManager) {
137+ options = {},
107- throw new Error('Preset manager not found');
138+ extractData = true,
139+ ) {
140+ const { presetName, instructName } = options;
141+ let requestData = { ...custom };
142+ const prompt = custom.prompt;
143+
144+ // Apply generation preset if specified
145+ if (presetName) {
146+ const presetManager = getPresetManager(this.TYPE);
147+ if (presetManager) {
148+ const preset = presetManager.getCompletionPresetByName(presetName);
149+ if (preset) {
150+ // Convert preset to payload and merge with custom parameters
151+ const presetPayload = this.presetToGeneratePayload(preset, {});
152+ requestData = { ...presetPayload, ...requestData };
153+ } else {
154+ console.warn(`Preset "${presetName}" not found, continuing with default settings`);
155+ }
156+ } else {
157+ console.warn('Preset manager not found, continuing with default settings');
158+ }
108159 }
109160
110- const preset = presetManager.getCompletionPresetByName(presetName);
161+ // Handle instruct formatting if requested
111162 if (!presetArray.isArray(prompt) && instructName) {
112163 throwconst newinstructPresetManager Error= getPresetManager('Preset not foundinstruct');
164+ let instructPreset = instructPresetManager?.getCompletionPresetByName(instructName);
165+ if (instructPreset) {
166+ // Clone the preset to avoid modifying the original
167+ instructPreset = structuredClone(instructPreset);
168+ instructPreset.macro = false;
169+ instructPreset.names_behavior = names_behavior_types.NONE;
170+
171+ // Format messages using instruct formatting
172+ const formattedMessages = [];
173+ for (const message of prompt) {
174+ let messageContent = message.content;
175+ if (!message.ignoreInstruct) {
176+ messageContent = formatInstructModeChat(
177+ message.role,
178+ message.content,
179+ message.role === 'user',
180+ false,
181+ undefined,
182+ undefined,
183+ undefined,
184+ undefined,
185+ instructPreset,
186+ );
187+
188+ // Add prompt formatting for the last message
189+ if (message === prompt[prompt.length - 1]) {
190+ messageContent += formatInstructModePrompt(
191+ undefined,
192+ false,
193+ undefined,
194+ undefined,
195+ undefined,
196+ false,
197+ false,
198+ instructPreset,
199+ );
200+ }
201+ }
202+ formattedMessages.push(messageContent);
203+ }
204+ requestData.prompt = formattedMessages.join('');
205+ if (instructPreset.output_suffix) {
206+ requestData.stop = [instructPreset.output_suffix];
207+ requestData.stopping_strings = [instructPreset.output_suffix];
208+ }
209+ } else {
210+ console.warn(`Instruct preset "${instructName}" not found, using basic formatting`);
211+ requestData.prompt = prompt.map(x => x.content).join('\n\n');
212+ }
213+ } else if (typeof prompt === 'string') {
214+ requestData.prompt = prompt;
215+ } else {
216+ requestData.prompt = prompt.map(x => x.content).join('\n\n');
113217 }
114218
115- const data = this.createRequestData({ ...preset, ...custom });
219+ // @ts-ignore
220+ const data = this.createRequestData(requestData);
116221
117222 return await this.sendRequest(data, extractData);
118223 }
224+
225+ /**
226+ * Converts a preset to a valid text completion payload.
227+ * Only supports temperature.
228+ * @param {Object} preset - The preset configuration
229+ * @param {Object} customPreset - Additional parameters to override preset values
230+ * @returns {Object} - Formatted payload for text completion API
231+ */
232+ static presetToGeneratePayload(preset, customPreset = {}) {
233+ if (!preset || typeof preset !== 'object') {
234+ throw new Error('Invalid preset: must be an object');
235+ }
236+
237+ // Merge preset with custom parameters
238+ const settings = { ...preset, ...customPreset };
239+
240+ // Initialize base payload with common parameters
241+ let payload = {
242+ 'temperature': settings.temp ? Number(settings.temp) : undefined,
243+ 'min_p': settings.min_p ? Number(settings.min_p) : undefined,
244+ };
245+
246+ // Remove undefined values to avoid API errors
247+ Object.keys(payload).forEach(key => {
248+ if (payload[key] === undefined) {
249+ delete payload[key];
250+ }
251+ });
252+
253+ return payload;
254+ }
119255}
120256
121257/**
@@ -129,7 +265,7 @@ export class ChatCompletionService {
129265 * @returns {ChatCompletionPayload}
130266 */
131267 static createRequestData({ messages, model, chat_completion_source, max_tokens, temperature, ...props }) {
132268 returnconst payload = {
133269 ...props,
134270 messages,
135271 model,
@@ -138,13 +274,22 @@ export class ChatCompletionService {
138274 temperature,
139275 stream: false,
140276 };
277+
278+ // Remove undefined values to avoid API errors
279+ Object.keys(payload).forEach(key => {
280+ if (payload[key] === undefined) {
281+ delete payload[key];
282+ }
283+ });
284+
285+ return payload;
141286 }
142287
143288 /**
144289 * Sends a chat completion request
145290 * @param {ChatCompletionPayload} data Request data
146291 * @param {boolean?} extractData Extract message from the response. Default true
147292 * @returns {Promise<stringExtractedData | any>} Extracted data or the raw response
148293 * @throws {Error}
149294 */
150295 static async sendRequest(data, extractData = true) {
@@ -161,29 +306,82 @@ export class ChatCompletionService {
161306 throw json;
162307 }
163308
164- return extractData ? extractMessageFromData(json, this.TYPE) : json;
309+ if (!extractData) {
310+ return json;
311+ }
312+
313+ return {
314+ content: extractMessageFromData(json, this.TYPE),
315+ reasoning: extractReasoningFromData(json, {
316+ mainApi: this.TYPE,
317+ textGenType: data.chat_completion_source,
318+ ignoreShowThoughts: true,
319+ }),
320+ };
165321 }
166322
167323 /**
168- * @param {string} presetName
324+ * Process and send a chat completion request with optional preset
169325 * @param {ChatCompletionPayload} custom
170326 * @param {booleanObject} extractData Extract message from theoptions response.- DefaultConfiguration trueoptions
171- * @returns {Promise<string | any>} Extracted data or the raw response
327+ * @param {string?} [options.presetName] - Name of the preset to use for generation settings
328+ * @param {boolean} extractData - Whether to extract structured data from response
329+ * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
172330 * @throws {Error}
173331 */
174332 static async sendRequestWithPresetprocessRequest(presetNamecustom, customoptions, extractData = true) {
175333 const presetManager{ presetName } = getPresetManager(this.TYPE)options;
176- if (!presetManager) {
334+ let requestData = { ...custom };
177- throw new Error('Preset manager not found');
178- }
179335
180- const preset = presetManager.getCompletionPresetByName(presetName);
336+ // Apply generation preset if specified
181337 if (!presetpresetName) {
182- throw new Error('Preset not found');
338+ const presetManager = getPresetManager(this.TYPE);
339+ if (presetManager) {
340+ const preset = presetManager.getCompletionPresetByName(presetName);
341+ if (preset) {
342+ // Convert preset to payload and merge with custom parameters
343+ const presetPayload = this.presetToGeneratePayload(preset, {});
344+ requestData = { ...presetPayload, ...requestData };
345+ } else {
346+ console.warn(`Preset "${presetName}" not found, continuing with default settings`);
347+ }
348+ } else {
349+ console.warn('Preset manager not found, continuing with default settings');
350+ }
183351 }
184352
185353 const data = this.createRequestData({ ...preset, ...custom }requestData);
186354
187355 return await this.sendRequest(data, extractData);
188356 }
357+
358+ /**
359+ * Converts a preset to a valid chat completion payload
360+ * Only supports temperature.
361+ * @param {Object} preset - The preset configuration
362+ * @param {Object} customParams - Additional parameters to override preset values
363+ * @returns {Object} - Formatted payload for chat completion API
364+ */
365+ static presetToGeneratePayload(preset, customParams = {}) {
366+ if (!preset || typeof preset !== 'object') {
367+ throw new Error('Invalid preset: must be an object');
368+ }
369+
370+ // Merge preset with custom parameters
371+ const settings = { ...preset, ...customParams };
372+
373+ // Initialize base payload with common parameters
374+ const payload = {
375+ temperature: settings.temperature ? Number(settings.temperature) : undefined,
376+ };
377+
378+ // Remove undefined values to avoid API errors
379+ Object.keys(payload).forEach(key => {
380+ if (payload[key] === undefined) {
381+ delete payload[key];
382+ }
383+ });
384+
385+ return payload;
386+ }
189387}
public/scripts/extensions/connection-manager/index.js+28 -6
@@ -1,4 +1,4 @@
11import { DOMPurify, Fuse } from '../../../lib.js';
22
33import { event_types, eventSource, main_api, saveSettingsDebounced } from '../../../script.js';
44import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
@@ -267,9 +267,14 @@ async function createConnectionProfile(forceName = null) {
267267 });
268268 const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n);
269269 const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken);
270270 constlet name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName, { rows: 2 });
271-
271+ // If it's cancelled, it will be false
272+ if (!name) {
273+ return null;
274+ }
275+ name = DOMPurify.sanitize(String(name));
272276 if (!name) {
277+ toastr.error('Name cannot be empty.');
273278 return null;
274279 }
275280
@@ -303,7 +308,8 @@ async function deleteConnectionProfile() {
303308 return;
304309 }
305310
306311 const nameprofile = extension_settings.connectionManager.profiles[index].name;
312+ const name = profile.name;
307313 const confirm = await Popup.show.confirm(t`Are you sure you want to delete the selected profile?`, name);
308314
309315 if (!confirm) {
@@ -313,6 +319,8 @@ async function deleteConnectionProfile() {
313319 extension_settings.connectionManager.profiles.splice(index, 1);
314320 extension_settings.connectionManager.selectedProfile = null;
315321 saveSettingsDebounced();
322+
323+ await eventSource.emit(event_types.CONNECTION_PROFILE_DELETED, profile);
316324}
317325
318326/**
@@ -512,6 +520,7 @@ async function renderDetailsContent(detailsContent) {
512520 saveSettingsDebounced();
513521 renderConnectionProfiles(profiles);
514522 await renderDetailsContent(detailsContent);
523+ await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
515524 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
516525 });
517526
@@ -523,9 +532,11 @@ async function renderDetailsContent(detailsContent) {
523532 console.log('No profile selected');
524533 return;
525534 }
535+ const oldProfile = structuredClone(profile);
526536 await updateConnectionProfile(profile);
527537 await renderDetailsContent(detailsContent);
528538 saveSettingsDebounced();
539+ await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
529540 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
530541 toastr.success('Connection profile updated', '', { timeOut: 1500 });
531542 });
@@ -559,7 +570,7 @@ async function renderDetailsContent(detailsContent) {
559570 return acc;
560571 }, {});
561572 const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'edit', { name: profile.name, settings }));
562573 constlet newName = await callGenericPopup(template, POPUP_TYPE.INPUT, profile.name, {
563574 rows: 2,
564575 customButtons: [{
565576 text: t`Save and Update`,
@@ -571,7 +582,13 @@ async function renderDetailsContent(detailsContent) {
571582 }],
572583 });
573584
585+ // If it's cancelled, it will be false
586+ if (!newName) {
587+ return;
588+ }
589+ newName = DOMPurify.sanitize(String(newName));
574590 if (!newName) {
591+ toastr.error('Name cannot be empty.');
575592 return;
576593 }
577594
@@ -584,6 +601,7 @@ async function renderDetailsContent(detailsContent) {
584601 return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
585602 }).get();
586603
604+ const oldProfile = structuredClone(profile);
587605 if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
588606 profile.exclude = newExcludeList;
589607 for (const command of newExcludeList) {
@@ -598,10 +616,11 @@ async function renderDetailsContent(detailsContent) {
598616
599617 if (profile.name !== newName) {
600618 toastr.success('Connection profile renamed.');
601619 profile.name = String(newName);
602620 }
603621
604622 saveSettingsDebounced();
623+ await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
605624 renderConnectionProfiles(profiles);
606625 await renderDetailsContent(detailsContent);
607626 });
@@ -704,6 +723,7 @@ async function renderDetailsContent(detailsContent) {
704723 saveSettingsDebounced();
705724 renderConnectionProfiles(profiles);
706725 await renderDetailsContent(detailsContent);
726+ await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
707727 return profile.name;
708728 },
709729 }));
@@ -718,9 +738,11 @@ async function renderDetailsContent(detailsContent) {
718738 toastr.warning('No profile selected.');
719739 return '';
720740 }
741+ const oldProfile = structuredClone(profile);
721742 await updateConnectionProfile(profile);
722743 await renderDetailsContent(detailsContent);
723744 saveSettingsDebounced();
745+ await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
724746 return profile.name;
725747 },
726748 }));
public/scripts/extensions/shared.js+308 -1
@@ -1,5 +1,6 @@
11import { CONNECT_API_MAP, getRequestHeaders } from '../../script.js';
22import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
3+import { t } from '../i18n.js';
34import { oai_settings } from '../openai.js';
45import { SECRET_KEYS, secret_state } from '../secrets.js';
56import { textgen_types, textgenerationwebui_settings } from '../textgen-settings.js';
@@ -273,3 +274,309 @@ export async function getWebLlmContextSize() {
273274 const model = await engine.getCurrentModelInfo();
274275 return model?.context_size;
275276}
277+
278+/**
279+ * It uses the profiles to send a generate request to the API. Doesn't support streaming.
280+ */
281+export class ConnectionManagerRequestService {
282+ static defaultSendRequestParams = {
283+ extractData: true,
284+ includePreset: true,
285+ includeInstruct: true,
286+ };
287+
288+ static getAllowedTypes() {
289+ return {
290+ openai: t`Chat Completion`,
291+ textgenerationwebui: t`Text Completion`,
292+ };
293+ }
294+
295+ /**
296+ * @param {string} profileId
297+ * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt
298+ * @param {number} maxTokens
299+ * @param {{extractData?: boolean, includePreset?: boolean, includeInstruct?: boolean}} custom - default values are true
300+ * @returns {Promise<import('../custom-request.js').ExtractedData | any>} Extracted data or the raw response
301+ */
302+ static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) {
303+ const { extractData, includePreset, includeInstruct } = { ...this.defaultSendRequestParams, ...custom };
304+
305+ const context = SillyTavern.getContext();
306+ if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
307+ throw new Error('Connection Manager is not available');
308+ }
309+
310+ const profile = context.extensionSettings.connectionManager.profiles.find((p) => p.id === profileId);
311+ const selectedApiMap = this.validateProfile(profile);
312+
313+ try {
314+ switch (selectedApiMap.selected) {
315+ case 'openai': {
316+ if (!selectedApiMap.source) {
317+ throw new Error(`API type ${selectedApiMap.selected} does not support chat completions`);
318+ }
319+
320+ const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }];
321+ return await context.ChatCompletionService.processRequest({
322+ messages,
323+ max_tokens: maxTokens,
324+ model: profile.model,
325+ chat_completion_source: selectedApiMap.source,
326+ }, {
327+ presetName: includePreset ? profile.preset : undefined,
328+ }, extractData);
329+ }
330+ case 'textgenerationwebui': {
331+ if (!selectedApiMap.type) {
332+ throw new Error(`API type ${selectedApiMap.selected} does not support text completions`);
333+ }
334+
335+ return await context.TextCompletionService.processRequest({
336+ prompt,
337+ max_tokens: maxTokens,
338+ model: profile.model,
339+ api_type: selectedApiMap.type,
340+ api_server: profile['api-url'],
341+ }, {
342+ instructName: includeInstruct ? profile.instruct : undefined,
343+ presetName: includePreset ? profile.preset : undefined,
344+ }, extractData);
345+ }
346+ default: {
347+ throw new Error(`Unknown API type ${selectedApiMap.selected}`);
348+ }
349+ }
350+ } catch (error) {
351+ throw new Error('API request failed', { cause: error });
352+ }
353+ }
354+
355+ /**
356+ * Respects allowed types.
357+ * @returns {import('./connection-manager/index.js').ConnectionProfile[]}
358+ */
359+ static getSupportedProfiles() {
360+ const context = SillyTavern.getContext();
361+ if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
362+ throw new Error('Connection Manager is not available');
363+ }
364+
365+ const profiles = context.extensionSettings.connectionManager.profiles;
366+ return profiles.filter((p) => this.isProfileSupported(p));
367+ }
368+
369+ /**
370+ * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
371+ * @returns {boolean}
372+ */
373+ static isProfileSupported(profile) {
374+ if (!profile) {
375+ return false;
376+ }
377+
378+ const apiMap = CONNECT_API_MAP[profile.api];
379+ if (!Object.hasOwn(this.getAllowedTypes(), apiMap.selected)) {
380+ return false;
381+ }
382+
383+ // Some providers not need model, like koboldcpp. But I don't want to check by provider.
384+ switch (apiMap.selected) {
385+ case 'openai':
386+ return !!apiMap.source;
387+ case 'textgenerationwebui':
388+ return !!apiMap.type;
389+ }
390+
391+ return false;
392+ }
393+
394+ /**
395+ * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
396+ * @return {import('../../script.js').ConnectAPIMap}
397+ * @throws {Error}
398+ */
399+ static validateProfile(profile) {
400+ if (!profile) {
401+ throw new Error('Could not find profile.');
402+ }
403+ if (!profile.api) {
404+ throw new Error('Select a connection profile that has an API');
405+ }
406+
407+ const context = SillyTavern.getContext();
408+ const selectedApiMap = context.CONNECT_API_MAP[profile.api];
409+ if (!selectedApiMap) {
410+ throw new Error(`Unknown API type ${profile.api}`);
411+ }
412+ if (!Object.hasOwn(this.getAllowedTypes(), selectedApiMap.selected)) {
413+ throw new Error(`API type ${selectedApiMap.selected} is not supported. Supported types: ${Object.values(this.getAllowedTypes()).join(', ')}`);
414+ }
415+
416+ return selectedApiMap;
417+ }
418+
419+ /**
420+ * Create profiles dropdown and updates select element accordingly. Use onChange, onCreate, unUpdate, onDelete callbacks for custom behaviour. e.g updating extension settings.
421+ * @param {string} selector
422+ * @param {string} initialSelectedProfileId
423+ * @param {(profile?: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onChange - 3 cases. 1- When user selects new profile. 2- When user deletes selected profile. 3- When user updates selected profile.
424+ * @param {(profile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onCreate
425+ * @param {(oldProfile: import('./connection-manager/index.js').ConnectionProfile, newProfile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} unUpdate
426+ * @param {(profile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onDelete
427+ */
428+ static handleDropdown(
429+ selector,
430+ initialSelectedProfileId,
431+ onChange = () => { },
432+ onCreate = () => { },
433+ unUpdate = () => { },
434+ onDelete = () => { },
435+ ) {
436+ const context = SillyTavern.getContext();
437+ if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
438+ throw new Error('Connection Manager is not available');
439+ }
440+
441+ /**
442+ * @type {JQuery<HTMLSelectElement>}
443+ */
444+ const dropdown = $(selector);
445+
446+ if (!dropdown || !dropdown.length) {
447+ throw new Error(`Could not find dropdown with selector ${selector}`);
448+ }
449+
450+ dropdown.empty();
451+
452+ // Create default option using document.createElement
453+ const defaultOption = document.createElement('option');
454+ defaultOption.value = '';
455+ defaultOption.textContent = 'Select a Connection Profile';
456+ defaultOption.dataset.i18n = 'Select a Connection Profile';
457+ dropdown.append(defaultOption);
458+
459+ const profiles = context.extensionSettings.connectionManager.profiles;
460+
461+ // Create optgroups using document.createElement
462+ const groups = {};
463+ for (const [apiType, groupLabel] of Object.entries(this.getAllowedTypes())) {
464+ const optgroup = document.createElement('optgroup');
465+ optgroup.label = groupLabel;
466+ groups[apiType] = optgroup;
467+ }
468+
469+ const sortedProfilesByGroup = {};
470+ for (const apiType of Object.keys(this.getAllowedTypes())) {
471+ sortedProfilesByGroup[apiType] = [];
472+ }
473+
474+ for (const profile of profiles) {
475+ if (this.isProfileSupported(profile)) {
476+ const apiMap = CONNECT_API_MAP[profile.api];
477+ if (sortedProfilesByGroup[apiMap.selected]) {
478+ sortedProfilesByGroup[apiMap.selected].push(profile);
479+ }
480+ }
481+ }
482+
483+ // Sort each group alphabetically and add to dropdown
484+ for (const [apiType, groupProfiles] of Object.entries(sortedProfilesByGroup)) {
485+ if (groupProfiles.length === 0) continue;
486+
487+ groupProfiles.sort((a, b) => a.name.localeCompare(b.name));
488+
489+ const group = groups[apiType];
490+ for (const profile of groupProfiles) {
491+ const option = document.createElement('option');
492+ option.value = profile.id;
493+ option.textContent = profile.name;
494+ group.appendChild(option);
495+ }
496+ }
497+
498+ for (const group of Object.values(groups)) {
499+ if (group.children.length > 0) {
500+ dropdown.append(group);
501+ }
502+ }
503+
504+ const selectedProfile = profiles.find((p) => p.id === initialSelectedProfileId);
505+ if (selectedProfile) {
506+ dropdown.val(selectedProfile.id);
507+ }
508+
509+ context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_CREATED, async (profile) => {
510+ const isSupported = this.isProfileSupported(profile);
511+ if (!isSupported) {
512+ return;
513+ }
514+
515+ const group = groups[CONNECT_API_MAP[profile.api].selected];
516+ const option = document.createElement('option');
517+ option.value = profile.id;
518+ option.textContent = profile.name;
519+ group.appendChild(option);
520+
521+ await onCreate(profile);
522+ });
523+
524+ context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_UPDATED, async (oldProfile, newProfile) => {
525+ const currentSelected = dropdown.val();
526+ const isSelectedProfile = currentSelected === oldProfile.id;
527+ await unUpdate(oldProfile, newProfile);
528+
529+ if (!this.isProfileSupported(newProfile)) {
530+ if (isSelectedProfile) {
531+ dropdown.val('');
532+ dropdown.trigger('change');
533+ }
534+ return;
535+ }
536+
537+ const group = groups[CONNECT_API_MAP[newProfile.api].selected];
538+ const oldOption = group.querySelector(`option[value="${oldProfile.id}"]`);
539+ if (oldOption) {
540+ oldOption.remove();
541+ }
542+
543+ const option = document.createElement('option');
544+ option.value = newProfile.id;
545+ option.textContent = newProfile.name;
546+ group.appendChild(option);
547+
548+ if (isSelectedProfile) {
549+ // Ackchyually, we don't need to reselect but what if id changes? It is not possible for now I couldn't stop myself.
550+ dropdown.val(newProfile.id);
551+ dropdown.trigger('change');
552+ }
553+ });
554+
555+ context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_DELETED, async (profile) => {
556+ const currentSelected = dropdown.val();
557+ const isSelectedProfile = currentSelected === profile.id;
558+ if (!this.isProfileSupported(profile)) {
559+ return;
560+ }
561+
562+ const group = groups[CONNECT_API_MAP[profile.api].selected];
563+ const optionToRemove = group.querySelector(`option[value="${profile.id}"]`);
564+ if (optionToRemove) {
565+ optionToRemove.remove();
566+ }
567+
568+ if (isSelectedProfile) {
569+ dropdown.val('');
570+ dropdown.trigger('change');
571+ }
572+
573+ await onDelete(profile);
574+ });
575+
576+ dropdown.on('change', async () => {
577+ const profileId = dropdown.val();
578+ const profile = context.extensionSettings.connectionManager.profiles.find((p) => p.id === profileId);
579+ await onChange(profile);
580+ });
581+ }
582+}
public/scripts/instruct-mode.js+35 -31
@@ -320,59 +320,61 @@ export const force_output_sequence = {
320320 * @param {string} name1 User name.
321321 * @param {string} name2 Character name.
322322 * @param {boolean|number} forceOutputSequence Force to use first/last output sequence (if configured).
323+ * @param {InstructSettings} customInstruct Custom instruct mode settings.
323324 * @returns {string} Formatted instruct mode chat message.
324325 */
325326export function formatInstructModeChat(name, mes, isUser, isNarrator, forceAvatar, name1, name2, forceOutputSequence, customInstruct = null) {
326327 letconst includeNamesinstruct = isNarratorstructuredClone(customInstruct ? false :? power_user.instruct.names_behavior === names_behavior_types.ALWAYS);
328+ let includeNames = isNarrator ? false : instruct.names_behavior === names_behavior_types.ALWAYS;
327329
328330 if (!isNarrator && power_user.instruct.names_behavior === names_behavior_types.FORCE && ((selected_group && name !== name1) || (forceAvatar && name !== name1))) {
329331 includeNames = true;
330332 }
331333
332334 function getPrefix() {
333335 if (isNarrator) {
334336 return power_user.instruct.system_same_as_user ? power_user.instruct.input_sequence : power_user.instruct.system_sequence;
335337 }
336338
337339 if (isUser) {
338340 if (forceOutputSequence === force_output_sequence.FIRST) {
339341 return power_user.instruct.first_input_sequence || power_user.instruct.input_sequence;
340342 }
341343
342344 if (forceOutputSequence === force_output_sequence.LAST) {
343345 return power_user.instruct.last_input_sequence || power_user.instruct.input_sequence;
344346 }
345347
346348 return power_user.instruct.input_sequence;
347349 }
348350
349351 if (forceOutputSequence === force_output_sequence.FIRST) {
350352 return power_user.instruct.first_output_sequence || power_user.instruct.output_sequence;
351353 }
352354
353355 if (forceOutputSequence === force_output_sequence.LAST) {
354356 return power_user.instruct.last_output_sequence || power_user.instruct.output_sequence;
355357 }
356358
357359 return power_user.instruct.output_sequence;
358360 }
359361
360362 function getSuffix() {
361363 if (isNarrator) {
362364 return power_user.instruct.system_same_as_user ? power_user.instruct.input_suffix : power_user.instruct.system_suffix;
363365 }
364366
365367 if (isUser) {
366368 return power_user.instruct.input_suffix;
367369 }
368370
369371 return power_user.instruct.output_suffix;
370372 }
371373
372374 let prefix = getPrefix() || '';
373375 let suffix = getSuffix() || '';
374376
375377 if (power_user.instruct.macro) {
376378 prefix = substituteParams(prefix, name1, name2);
377379 prefix = prefix.replace(/{{name}}/gi, name || 'System');
378380
@@ -380,11 +382,11 @@ export function formatInstructModeChat(name, mes, isUser, isNarrator, forceAvata
380382 suffix = suffix.replace(/{{name}}/gi, name || 'System');
381383 }
382384
383385 if (!suffix && power_user.instruct.wrap) {
384386 suffix = '\n';
385387 }
386388
387389 const separator = power_user.instruct.wrap ? '\n' : '';
388390
389391 // Don't include the name if it's empty
390392 const textArray = includeNames && name ? [prefix, `${name}: ${mes}` + suffix] : [prefix, mes + suffix];
@@ -504,30 +506,32 @@ export function formatInstructModeExamples(mesExamplesArray, name1, name2) {
504506 * @param {string} name2 Character name.
505507 * @param {boolean} isQuiet Is quiet mode generation.
506508 * @param {boolean} isQuietToLoud Is quiet to loud generation.
509+ * @param {InstructSettings} customInstruct Custom instruct settings.
507510 * @returns {string} Formatted instruct mode last prompt line.
508511 */
509512export function formatInstructModePrompt(name, isImpersonate, promptBias, name1, name2, isQuiet, isQuietToLoud, customInstruct = null) {
510- const includeNames = name && (power_user.instruct.names_behavior === names_behavior_types.ALWAYS || (!!selected_group && power_user.instruct.names_behavior === names_behavior_types.FORCE)) && !(isQuiet && !isQuietToLoud);
513+ const instruct = structuredClone(customInstruct ?? power_user.instruct);
514+ const includeNames = name && (instruct.names_behavior === names_behavior_types.ALWAYS || (!!selected_group && instruct.names_behavior === names_behavior_types.FORCE)) && !(isQuiet && !isQuietToLoud);
511515
512516 function getSequence() {
513517 // User impersonation prompt
514518 if (isImpersonate) {
515519 return power_user.instruct.input_sequence;
516520 }
517521
518522 // Neutral / system / quiet prompt
519523 // Use a special quiet instruct sequence if defined, or assistant's output sequence otherwise
520524 if (isQuiet && !isQuietToLoud) {
521525 return power_user.instruct.last_system_sequence || power_user.instruct.output_sequence;
522526 }
523527
524528 // Quiet in-character prompt
525529 if (isQuiet && isQuietToLoud) {
526530 return power_user.instruct.last_output_sequence || power_user.instruct.output_sequence;
527531 }
528532
529533 // Default AI response
530534 return power_user.instruct.last_output_sequence || power_user.instruct.output_sequence;
531535 }
532536
533537 let sequence = getSequence() || '';
@@ -536,21 +540,21 @@ export function formatInstructModePrompt(name, isImpersonate, promptBias, name1,
536540 // A hack for Mistral's formatting that has a normal output sequence ending with a space
537541 if (
538542 includeNames &&
539543 power_user.instruct.last_output_sequence &&
540544 power_user.instruct.output_sequence &&
541545 sequence === power_user.instruct.last_output_sequence &&
542546 /\s$/.test(power_user.instruct.output_sequence) &&
543547 !/\s$/.test(power_user.instruct.last_output_sequence)
544548 ) {
545549 nameFiller = power_user.instruct.output_sequence.slice(-1);
546550 }
547551
548552 if (power_user.instruct.macro) {
549553 sequence = substituteParams(sequence, name1, name2);
550554 sequence = sequence.replace(/{{name}}/gi, name || 'System');
551555 }
552556
553557 const separator = power_user.instruct.wrap ? '\n' : '';
554558 let text = includeNames ? (separator + sequence + separator + nameFiller + `${name}:`) : (separator + sequence);
555559
556560 // Quiet prompt already has a newline at the end
@@ -562,7 +566,7 @@ export function formatInstructModePrompt(name, isImpersonate, promptBias, name1,
562566 text += (includeNames ? promptBias : (separator + promptBias.trimStart()));
563567 }
564568
565569 return (power_user.instruct.wrap ? text.trimEnd() : text) + (includeNames ? '' : separator);
566570}
567571
568572/**
public/scripts/power-user.js+2 -0
@@ -218,7 +218,9 @@ let power_user = {
218218 system_sequence: '',
219219 system_suffix: '',
220220 last_system_sequence: '',
221+ first_input_sequence: '',
221222 first_output_sequence: '',
223+ last_input_sequence: '',
222224 last_output_sequence: '',
223225 system_sequence_prefix: '',
224226 system_sequence_suffix: '',
public/scripts/reasoning.js+10 -5
@@ -57,19 +57,24 @@ function toggleReasoningAutoExpand() {
5757 * @param {object} data Response data
5858 * @returns {string} Extracted reasoning
5959 */
6060export function extractReasoningFromData(data), {
61- switch (main_api) {
61+ mainApi = null,
62+ ignoreShowThoughts = false,
63+ textGenType = null,
64+ chatCompletionSource = null
65+} = {}) {
66+ switch (mainApi ?? main_api) {
6267 case 'textgenerationwebui':
6368 switch (textGenType ?? textgenerationwebui_settings.type) {
6469 case textgen_types.OPENROUTER:
6570 return data?.choices?.[0]?.reasoning ?? '';
6671 }
6772 break;
6873
6974 case 'openai':
7075 if (!ignoreShowThoughts && !oai_settings.show_thoughts) break;
7176
7277 switch (chatCompletionSource ?? oai_settings.chat_completion_source) {
7378 case chat_completion_sources.DEEPSEEK:
7479 return data?.choices?.[0]?.message?.reasoning_content ?? '';
7580 case chat_completion_sources.OPENROUTER:
public/scripts/st-context.js+2 -0
@@ -80,6 +80,7 @@ import { timestampToMoment, uuidv4 } from './utils.js';
8080import { getGlobalVariable, getLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js';
8181import { convertCharacterBook, loadWorldInfo, saveWorldInfo, updateWorldInfoList } from './world-info.js';
8282import { ChatCompletionService, TextCompletionService } from './custom-request.js';
83+import { ConnectionManagerRequestService } from './extensions/shared.js';
8384import { updateReasoningUI, parseReasoningFromString } from './reasoning.js';
8485
8586export function getContext() {
@@ -215,6 +216,7 @@ export function getContext() {
215216 clearChat,
216217 ChatCompletionService,
217218 TextCompletionService,
219+ ConnectionManagerRequestService,
218220 updateReasoningUI,
219221 parseReasoningFromString,
220222 unshallowCharacter,
public/scripts/textgen-settings.js+1 -1
@@ -86,7 +86,7 @@ const OOBA_DEFAULT_ORDER = [
8686 'encoder_repetition_penalty',
8787 'no_repeat_ngram',
8888];
8989export const APHRODITE_DEFAULT_ORDER = [
9090 'dry',
9191 'penalties',
9292 'no_repeat_ngram',