Blame Raw
Cohee · 51ad27fb · · 783 lines (31.7 KB)
5 contributors
1import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js';
2import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
3import { t } from '../i18n.js';
4import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js';
5import { SECRET_KEYS, secret_state } from '../secrets.js';
6import { textgen_types, textgenerationwebui_settings } from '../textgen-settings.js';
7import { getTokenCountAsync } from '../tokenizers.js';
8import { createThumbnail, isValidUrl } from '../utils.js';
9
10/**
11 * Generates a caption for an image using a multimodal model.
12 * @param {string} base64Img Base64 encoded image
13 * @param {string} prompt Prompt to use for captioning
14 * @returns {Promise<string>} Generated caption
15 */
16export async function getMultimodalCaption(base64Img, prompt) {
17 const useReverseProxy =
18 (['openai', 'anthropic', 'google', 'mistral', 'vertexai', 'xai', 'zai', 'moonshot'].includes(extension_settings.caption.multimodal_api))
19 && extension_settings.caption.allow_reverse_proxy
20 && oai_settings.reverse_proxy
21 && isValidUrl(oai_settings.reverse_proxy);
22
23 throwIfInvalidModel(useReverseProxy);
24
25 // OpenRouter has a payload limit of ~2MB. Google is 4MB, but we love democracy.
26 // Ooba requires all images to be JPEGs. Koboldcpp just asked nicely.
27 const isOllama = extension_settings.caption.multimodal_api === 'ollama';
28 const isLlamaCpp = extension_settings.caption.multimodal_api === 'llamacpp';
29 const isCustom = extension_settings.caption.multimodal_api === 'custom';
30 const isOoba = extension_settings.caption.multimodal_api === 'ooba';
31 const isKoboldCpp = extension_settings.caption.multimodal_api === 'koboldcpp';
32 const isVllm = extension_settings.caption.multimodal_api === 'vllm';
33 const base64Bytes = base64Img.length * 0.75;
34 const compressionLimit = 2 * 1024 * 1024;
35 const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
36 const mimeType = base64Img?.split(';')?.[0]?.split(':')?.[1] || 'image/jpeg';
37 const isImage = mimeType.startsWith('image/');
38 const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api);
39 if ((isImage && thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) {
40 const maxSide = 2048;
41 base64Img = await createThumbnail(base64Img, maxSide, maxSide);
42 } else if (isImage && !safeMimeTypes.includes(mimeType)) {
43 base64Img = await createThumbnail(base64Img, null, null);
44 }
45 if (isOllama && base64Img.startsWith('data:image/')) {
46 base64Img = base64Img.split(',')[1];
47 }
48
49 const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : '';
50 const proxyPassword = useReverseProxy ? oai_settings.proxy_password : '';
51
52 const requestBody = {
53 image: base64Img,
54 prompt: prompt,
55 reverse_proxy: proxyUrl,
56 proxy_password: proxyPassword,
57 api: extension_settings.caption.multimodal_api || 'openai',
58 model: extension_settings.caption.multimodal_model || 'gpt-4-turbo',
59 };
60
61 // Add Vertex AI specific parameters if using Vertex AI
62 if (extension_settings.caption.multimodal_api === 'vertexai') {
63 requestBody.vertexai_auth_mode = oai_settings.vertexai_auth_mode;
64 requestBody.vertexai_region = oai_settings.vertexai_region;
65 requestBody.vertexai_express_project_id = oai_settings.vertexai_express_project_id;
66 }
67
68 if (isOllama) {
69 if (extension_settings.caption.multimodal_model === 'ollama_current') {
70 requestBody.model = textgenerationwebui_settings.ollama_model;
71 }
72
73 if (extension_settings.caption.multimodal_model === 'ollama_custom') {
74 requestBody.model = extension_settings.caption.ollama_custom_model;
75 }
76
77 requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
78 ? extension_settings.caption.alt_endpoint_url
79 : textgenerationwebui_settings.server_urls[textgen_types.OLLAMA];
80 }
81
82 if (isVllm) {
83 if (extension_settings.caption.multimodal_model === 'vllm_current') {
84 requestBody.model = textgenerationwebui_settings.vllm_model;
85 }
86
87 requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
88 ? extension_settings.caption.alt_endpoint_url
89 : textgenerationwebui_settings.server_urls[textgen_types.VLLM];
90 }
91
92 if (isLlamaCpp) {
93 requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
94 ? extension_settings.caption.alt_endpoint_url
95 : textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP];
96 }
97
98 if (isOoba) {
99 requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
100 ? extension_settings.caption.alt_endpoint_url
101 : textgenerationwebui_settings.server_urls[textgen_types.OOBA];
102 }
103
104 if (isKoboldCpp) {
105 requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
106 ? extension_settings.caption.alt_endpoint_url
107 : textgenerationwebui_settings.server_urls[textgen_types.KOBOLDCPP];
108 }
109
110 if (isCustom) {
111 if (extension_settings.caption.multimodal_model === 'custom_current') {
112 requestBody.model = oai_settings.custom_model || '';
113 }
114
115 if (extension_settings.caption.multimodal_model === 'custom_custom') {
116 requestBody.model = extension_settings.caption.custom_model || '';
117 }
118
119 requestBody.server_url = oai_settings.custom_url;
120 requestBody.custom_include_headers = oai_settings.custom_include_headers;
121 requestBody.custom_include_body = oai_settings.custom_include_body;
122 requestBody.custom_exclude_body = oai_settings.custom_exclude_body;
123 }
124
125 if (extension_settings.caption.multimodal_api === 'zai') {
126 requestBody.zai_endpoint = oai_settings.zai_endpoint || ZAI_ENDPOINT.COMMON;
127 }
128
129 if (extension_settings.caption.multimodal_api === 'workers_ai') {
130 requestBody.workers_ai_account_id = oai_settings.workers_ai_account_id;
131 }
132
133 function getEndpointUrl() {
134 switch (extension_settings.caption.multimodal_api) {
135 case 'google':
136 case 'vertexai':
137 return '/api/google/caption-image';
138 case 'anthropic':
139 return '/api/anthropic/caption-image';
140 case 'ollama':
141 return '/api/backends/text-completions/ollama/caption-image';
142 default:
143 return '/api/openai/caption-image';
144 }
145 }
146
147 const apiResult = await fetch(getEndpointUrl(), {
148 method: 'POST',
149 headers: getRequestHeaders(),
150 body: JSON.stringify(requestBody),
151 });
152
153 if (!apiResult.ok) {
154 throw new Error('Failed to caption image via Multimodal API.');
155 }
156
157 const { caption } = await apiResult.json();
158 return String(caption).trim();
159}
160
161function throwIfInvalidModel(useReverseProxy) {
162 const altEndpointEnabled = extension_settings.caption.alt_endpoint_enabled;
163 const altEndpointUrl = extension_settings.caption.alt_endpoint_url;
164 const multimodalModel = extension_settings.caption.multimodal_model;
165 const multimodalApi = extension_settings.caption.multimodal_api;
166
167 if (altEndpointEnabled && ['llamacpp', 'ooba', 'koboldcpp', 'vllm', 'ollama'].includes(multimodalApi) && !altEndpointUrl) {
168 throw new Error('Secondary endpoint URL is not set.');
169 }
170
171 if (multimodalApi === 'openai' && !secret_state[SECRET_KEYS.OPENAI] && !useReverseProxy) {
172 throw new Error('OpenAI API key is not set.');
173 }
174
175 if (multimodalApi === 'openrouter' && !secret_state[SECRET_KEYS.OPENROUTER]) {
176 throw new Error('OpenRouter API key is not set.');
177 }
178
179 if (multimodalApi === 'anthropic' && !secret_state[SECRET_KEYS.CLAUDE] && !useReverseProxy) {
180 throw new Error('Anthropic (Claude) API key is not set.');
181 }
182
183 if (multimodalApi === 'groq' && !secret_state[SECRET_KEYS.GROQ]) {
184 throw new Error('Groq API key is not set.');
185 }
186
187 if (multimodalApi === 'google' && !secret_state[SECRET_KEYS.MAKERSUITE] && !useReverseProxy) {
188 throw new Error('Google AI Studio API key is not set.');
189 }
190
191 if (multimodalApi === 'vertexai' && !useReverseProxy) {
192 // Check based on authentication mode
193 const authMode = oai_settings.vertexai_auth_mode || 'express';
194
195 if (authMode === 'express') {
196 // Express mode requires API key
197 if (!secret_state[SECRET_KEYS.VERTEXAI]) {
198 throw new Error('Google Vertex AI API key is not set for Express mode.');
199 }
200 } else if (authMode === 'full') {
201 // Full mode requires Service Account JSON and region settings
202 if (!secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) {
203 throw new Error('Service Account JSON is required for Vertex AI Full mode. Please validate and save your Service Account JSON.');
204 }
205 if (!oai_settings.vertexai_region) {
206 throw new Error('Region is required for Vertex AI Full mode.');
207 }
208 }
209 }
210
211 if (multimodalApi === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] && !useReverseProxy) {
212 throw new Error('Mistral AI API key is not set.');
213 }
214
215 if (multimodalApi === 'cohere' && !secret_state[SECRET_KEYS.COHERE]) {
216 throw new Error('Cohere API key is not set.');
217 }
218
219 if (multimodalApi === 'xai' && !secret_state[SECRET_KEYS.XAI] && !useReverseProxy) {
220 throw new Error('xAI API key is not set.');
221 }
222
223 if (multimodalApi === 'ollama' && !textgenerationwebui_settings.server_urls[textgen_types.OLLAMA] && !altEndpointEnabled) {
224 throw new Error('Ollama server URL is not set.');
225 }
226
227 if (multimodalApi === 'ollama' && multimodalModel === 'ollama_current' && !textgenerationwebui_settings.ollama_model) {
228 throw new Error('Ollama model is not set.');
229 }
230
231 if (multimodalApi === 'ollama' && multimodalModel === 'ollama_custom' && !extension_settings.caption.ollama_custom_model) {
232 throw new Error('Ollama custom model tag is not set.');
233 }
234
235 if (multimodalApi === 'llamacpp' && !textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP] && !altEndpointEnabled) {
236 throw new Error('LlamaCPP server URL is not set.');
237 }
238
239 if (multimodalApi === 'ooba' && !textgenerationwebui_settings.server_urls[textgen_types.OOBA] && !altEndpointEnabled) {
240 throw new Error('Text Generation WebUI server URL is not set.');
241 }
242
243 if (multimodalApi === 'koboldcpp' && !textgenerationwebui_settings.server_urls[textgen_types.KOBOLDCPP] && !altEndpointEnabled) {
244 throw new Error('KoboldCpp server URL is not set.');
245 }
246
247 if (multimodalApi === 'vllm' && !textgenerationwebui_settings.server_urls[textgen_types.VLLM] && !altEndpointEnabled) {
248 throw new Error('vLLM server URL is not set.');
249 }
250
251 if (multimodalApi === 'vllm' && multimodalModel === 'vllm_current' && !textgenerationwebui_settings.vllm_model) {
252 throw new Error('vLLM model is not set.');
253 }
254
255 if (multimodalApi === 'custom' && !oai_settings.custom_url) {
256 throw new Error('Custom API URL is not set.');
257 }
258
259 if (multimodalApi === 'custom' && multimodalModel === 'custom_custom' && !extension_settings.caption.custom_model) {
260 throw new Error('Custom OpenAI-compatible Model ID is not set.');
261 }
262
263 if (multimodalApi === 'aimlapi' && !secret_state[SECRET_KEYS.AIMLAPI]) {
264 throw new Error('AI/ML API key is not set.');
265 }
266
267 if (multimodalApi === 'moonshot' && !secret_state[SECRET_KEYS.MOONSHOT]) {
268 throw new Error('Moonshot AI API key is not set.');
269 }
270
271 if (multimodalApi === 'nanogpt' && !secret_state[SECRET_KEYS.NANOGPT]) {
272 throw new Error('NanoGPT API key is not set.');
273 }
274
275 if (multimodalApi === 'electronhub' && !secret_state[SECRET_KEYS.ELECTRONHUB]) {
276 throw new Error('Electron Hub API key is not set.');
277 }
278
279 if (multimodalApi === 'chutes' && !secret_state[SECRET_KEYS.CHUTES]) {
280 throw new Error('Chutes API key is not set.');
281 }
282
283 if (multimodalApi === 'zai' && !secret_state[SECRET_KEYS.ZAI]) {
284 throw new Error('Z.AI API key is not set.');
285 }
286
287 if (multimodalApi === 'pollinations' && !secret_state[SECRET_KEYS.POLLINATIONS]) {
288 throw new Error('Pollinations API key is not set.');
289 }
290
291 if (multimodalApi === 'workers_ai' && (!secret_state[SECRET_KEYS.WORKERS_AI] || !oai_settings.workers_ai_account_id)) {
292 throw new Error('Workers AI API key or account ID is not set.');
293 }
294}
295
296/**
297 * Check if the WebLLM extension is installed and supported.
298 * @returns {boolean} Whether the extension is installed and supported
299 */
300export function isWebLlmSupported() {
301 if (!('gpu' in navigator)) {
302 const warningKey = 'webllm_browser_warning_shown';
303 if (!sessionStorage.getItem(warningKey)) {
304 toastr.error('Your browser does not support the WebGPU API. Please use a different browser.', 'WebLLM', {
305 preventDuplicates: true,
306 timeOut: 0,
307 extendedTimeOut: 0,
308 });
309 sessionStorage.setItem(warningKey, '1');
310 }
311 return false;
312 }
313
314 if (!('llm' in SillyTavern)) {
315 const warningKey = 'webllm_extension_warning_shown';
316 if (!sessionStorage.getItem(warningKey)) {
317 toastr.error('WebLLM extension is not installed. Click here to install it.', 'WebLLM', {
318 timeOut: 0,
319 extendedTimeOut: 0,
320 preventDuplicates: true,
321 onclick: () => openThirdPartyExtensionMenu('https://github.com/SillyTavern/Extension-WebLLM'),
322 });
323 sessionStorage.setItem(warningKey, '1');
324 }
325 return false;
326 }
327
328 return true;
329}
330
331/**
332 * Generates text in response to a chat prompt using WebLLM.
333 * @param {any[]} messages Messages to use for generating
334 * @param {object} params Additional parameters
335 * @returns {Promise<string>} Generated response
336 */
337export async function generateWebLlmChatPrompt(messages, params = {}) {
338 if (!isWebLlmSupported()) {
339 throw new Error('WebLLM extension is not installed.');
340 }
341
342 console.debug('WebLLM chat completion request:', messages, params);
343 const engine = SillyTavern.llm;
344 const response = await engine.generateChatPrompt(messages, params);
345 console.debug('WebLLM chat completion response:', response);
346 return response;
347}
348
349/**
350 * Counts the number of tokens in the provided text using WebLLM's default model.
351 * Fallbacks to the current model's tokenizer if WebLLM token count fails.
352 * @param {string} text Text to count tokens in
353 * @returns {Promise<number>} Number of tokens in the text
354 */
355export async function countWebLlmTokens(text) {
356 if (!isWebLlmSupported()) {
357 throw new Error('WebLLM extension is not installed.');
358 }
359
360 try {
361 const engine = SillyTavern.llm;
362 const response = await engine.countTokens(text);
363 return response;
364 } catch (error) {
365 // Fallback to using current model's tokenizer
366 return await getTokenCountAsync(text);
367 }
368}
369
370/**
371 * Gets the size of the context in the WebLLM's default model.
372 * @returns {Promise<number>} Size of the context in the WebLLM model
373 */
374export async function getWebLlmContextSize() {
375 if (!isWebLlmSupported()) {
376 throw new Error('WebLLM extension is not installed.');
377 }
378
379 const engine = SillyTavern.llm;
380 await engine.loadModel();
381 const model = await engine.getCurrentModelInfo();
382 return model?.context_size;
383}
384
385/**
386 * It uses the profiles to send a generate request to the API.
387 */
388export class ConnectionManagerRequestService {
389 static defaultSendRequestParams = {
390 stream: false,
391 signal: null,
392 extractData: true,
393 includePreset: true,
394 includeInstruct: true,
395 instructSettings: {},
396 };
397
398 static getAllowedTypes() {
399 return {
400 openai: t`Chat Completion`,
401 textgenerationwebui: t`Text Completion`,
402 };
403 }
404
405 /**
406 * @param {string} profileId
407 * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt
408 * @param {number} maxTokens
409 * @param {Object} custom
410 * @param {boolean?} [custom.stream=false]
411 * @param {AbortSignal?} [custom.signal]
412 * @param {boolean?} [custom.extractData=true]
413 * @param {boolean?} [custom.includePreset=true]
414 * @param {boolean?} [custom.includeInstruct=true]
415 * @param {Partial<InstructSettings>?} [custom.instructSettings] Override instruct settings
416 * @param {Record<string, any>} [overridePayload] - Override payload for the request
417 * @returns {Promise<import('../custom-request.js').ExtractedData | (() => AsyncGenerator<import('../custom-request.js').StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
418 */
419 static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams, overridePayload = {}) {
420 const { stream, signal, extractData, includePreset, includeInstruct, instructSettings } = { ...this.defaultSendRequestParams, ...custom };
421
422 const context = SillyTavern.getContext();
423 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
424 throw new Error('Connection Manager is not available');
425 }
426
427 const profile = this.getProfile(profileId);
428 const selectedApiMap = this.validateProfile(profile);
429
430 try {
431 switch (selectedApiMap.selected) {
432 case 'openai': {
433 if (!selectedApiMap.source) {
434 throw new Error(`API type ${selectedApiMap.selected} does not support chat completions`);
435 }
436
437 const proxyPreset = proxies.find((p) => p.name === profile.proxy);
438
439 const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }];
440 return await context.ChatCompletionService.processRequest({
441 stream,
442 messages,
443 max_tokens: maxTokens,
444 model: profile.model,
445 chat_completion_source: selectedApiMap.source,
446 secret_id: profile['secret-id'],
447 custom_url: profile['api-url'],
448 vertexai_region: profile['api-url'],
449 zai_endpoint: profile['api-url'],
450 siliconflow_endpoint: profile['api-url'],
451 minimax_endpoint: profile['api-url'],
452 reverse_proxy: proxyPreset?.url,
453 proxy_password: proxyPreset?.password,
454 custom_prompt_post_processing: profile['prompt-post-processing'],
455 ...overridePayload,
456 }, {
457 presetName: includePreset ? profile.preset : undefined,
458 }, extractData, signal);
459 }
460 case 'textgenerationwebui': {
461 if (!selectedApiMap.type) {
462 throw new Error(`API type ${selectedApiMap.selected} does not support text completions`);
463 }
464
465 return await context.TextCompletionService.processRequest({
466 stream,
467 prompt,
468 max_tokens: maxTokens,
469 model: profile.model,
470 api_type: selectedApiMap.type,
471 api_server: profile['api-url'],
472 secret_id: profile['secret-id'],
473 ...overridePayload,
474 }, {
475 instructName: includeInstruct ? profile.instruct : undefined,
476 presetName: includePreset ? profile.preset : undefined,
477 instructSettings: includeInstruct ? instructSettings : undefined,
478 }, extractData, signal);
479 }
480 default: {
481 throw new Error(`Unknown API type ${selectedApiMap.selected}`);
482 }
483 }
484 } catch (error) {
485 throw new Error('API request failed', { cause: error });
486 }
487 }
488
489 /**
490 * If using text completion, return a formatted prompt string given an array of messages, a given profile ID, and optional instruct settings.
491 * If using chat completion, simply return the given prompt as-is.
492 * @param {ChatCompletionMessage[]} prompt An array of prompt messages.
493 * @param {string} profileId ID of a given connection profile (from which to infer a completion preset).
494 * @param {InstructSettings} instructSettings optional instruct settings
495 */
496 static constructPrompt(prompt, profileId, instructSettings = null) {
497 const context = SillyTavern.getContext();
498 const profile = this.getProfile(profileId);
499 const selectedApiMap = this.validateProfile(profile);
500 const instructName = profile.instruct;
501
502 switch (selectedApiMap.selected) {
503 case 'openai': {
504 if (!selectedApiMap.source) {
505 throw new Error(`API type ${selectedApiMap.selected} does not support chat completions`);
506 }
507 return prompt;
508 }
509 case 'textgenerationwebui': {
510 if (!selectedApiMap.type) {
511 throw new Error(`API type ${selectedApiMap.selected} does not support text completions`);
512 }
513 return context.TextCompletionService.constructPrompt(prompt, instructName, instructSettings);
514 }
515 default: {
516 throw new Error(`Unknown API type ${selectedApiMap.selected}`);
517 }
518 }
519 }
520
521 /**
522 * Respects allowed types.
523 * @returns {import('./connection-manager/index.js').ConnectionProfile[]}
524 */
525 static getSupportedProfiles() {
526 const context = SillyTavern.getContext();
527 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
528 throw new Error('Connection Manager is not available');
529 }
530
531 const profiles = context.extensionSettings.connectionManager.profiles;
532 return profiles.filter((p) => this.isProfileSupported(p));
533 }
534
535 /**
536 * Return profile data given the profile ID
537 * @param {string} profileId
538 * @returns {import('./connection-manager/index.js').ConnectionProfile?} [profile]
539 * @throws {Error}
540 */
541 static getProfile(profileId) {
542 const profile = SillyTavern.getContext().extensionSettings.connectionManager.profiles.find((p) => p.id === profileId);
543 if (!profile) throw new Error(`Profile not found (ID: ${profileId})`);
544 return profile;
545 }
546
547 /**
548 * Creates a model icon Image element for the given profile (or the currently selected profile).
549 * Returns null if the profile is not found, has no API, or Connection Manager is unavailable.
550 * @param {string} [profileId] - Profile ID. If omitted, uses the currently selected profile.
551 * @returns {HTMLImageElement | null}
552 */
553 static getProfileIcon(profileId) {
554 if ((SillyTavern.getContext()).extensionSettings.disabledExtensions.includes('connection-manager')) {
555 return null;
556 }
557
558 const id = profileId ?? (SillyTavern.getContext()).extensionSettings.connectionManager.selectedProfile;
559 if (!id) return null;
560
561 try {
562 const profile = this.getProfile(id);
563 if (!profile?.api) return null;
564 return createModelIcon(profile.api, profile.model);
565 } catch {
566 return null;
567 }
568 }
569
570 /**
571 * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
572 * @returns {boolean}
573 */
574 static isProfileSupported(profile) {
575 if (!profile || !profile.api) {
576 return false;
577 }
578
579 const apiMap = CONNECT_API_MAP[profile.api];
580 if (!Object.hasOwn(this.getAllowedTypes(), apiMap.selected)) {
581 return false;
582 }
583
584 // Some providers not need model, like koboldcpp. But I don't want to check by provider.
585 switch (apiMap.selected) {
586 case 'openai':
587 return !!apiMap.source;
588 case 'textgenerationwebui':
589 return !!apiMap.type;
590 }
591
592 return false;
593 }
594
595 /**
596 * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
597 * @return {import('../slash-commands.js').ConnectAPIMap}
598 * @throws {Error}
599 */
600 static validateProfile(profile) {
601 if (!profile) {
602 throw new Error('Could not find profile.');
603 }
604 if (!profile.api) {
605 throw new Error('Select a connection profile that has an API');
606 }
607
608 const context = SillyTavern.getContext();
609 const selectedApiMap = context.CONNECT_API_MAP[profile.api];
610 if (!selectedApiMap) {
611 throw new Error(`Unknown API type ${profile.api}`);
612 }
613 if (!Object.hasOwn(this.getAllowedTypes(), selectedApiMap.selected)) {
614 throw new Error(`API type ${selectedApiMap.selected} is not supported. Supported types: ${Object.values(this.getAllowedTypes()).join(', ')}`);
615 }
616
617 return selectedApiMap;
618 }
619
620 /**
621 * Create profiles dropdown and updates select element accordingly. Use onChange, onCreate, unUpdate, onDelete callbacks for custom behaviour. e.g updating extension settings.
622 * @param {string} selector
623 * @param {string} initialSelectedProfileId
624 * @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.
625 * @param {(profile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onCreate
626 * @param {(oldProfile: import('./connection-manager/index.js').ConnectionProfile, newProfile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} unUpdate
627 * @param {(profile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onDelete
628 */
629 static handleDropdown(
630 selector,
631 initialSelectedProfileId,
632 onChange = () => { },
633 onCreate = () => { },
634 unUpdate = () => { },
635 onDelete = () => { },
636 ) {
637 const context = SillyTavern.getContext();
638 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
639 throw new Error('Connection Manager is not available');
640 }
641
642 /**
643 * @type {JQuery<HTMLSelectElement>}
644 */
645 const dropdown = $(selector);
646
647 if (!dropdown || !dropdown.length) {
648 throw new Error(`Could not find dropdown with selector ${selector}`);
649 }
650
651 dropdown.empty();
652
653 // Create default option using document.createElement
654 const defaultOption = document.createElement('option');
655 defaultOption.value = '';
656 defaultOption.textContent = 'Select a Connection Profile';
657 defaultOption.dataset.i18n = 'Select a Connection Profile';
658 dropdown.append(defaultOption);
659
660 const profiles = context.extensionSettings.connectionManager.profiles;
661
662 // Create optgroups using document.createElement
663 const groups = {};
664 for (const [apiType, groupLabel] of Object.entries(this.getAllowedTypes())) {
665 const optgroup = document.createElement('optgroup');
666 optgroup.label = groupLabel;
667 groups[apiType] = optgroup;
668 }
669
670 const sortedProfilesByGroup = {};
671 for (const apiType of Object.keys(this.getAllowedTypes())) {
672 sortedProfilesByGroup[apiType] = [];
673 }
674
675 for (const profile of profiles) {
676 if (this.isProfileSupported(profile)) {
677 const apiMap = CONNECT_API_MAP[profile.api];
678 if (sortedProfilesByGroup[apiMap.selected]) {
679 sortedProfilesByGroup[apiMap.selected].push(profile);
680 }
681 }
682 }
683
684 // Sort each group alphabetically and add to dropdown
685 for (const [apiType, groupProfiles] of Object.entries(sortedProfilesByGroup)) {
686 if (groupProfiles.length === 0) continue;
687
688 groupProfiles.sort((a, b) => a.name.localeCompare(b.name));
689
690 const group = groups[apiType];
691 for (const profile of groupProfiles) {
692 const option = document.createElement('option');
693 option.value = profile.id;
694 option.textContent = profile.name;
695 group.appendChild(option);
696 }
697 }
698
699 for (const group of Object.values(groups)) {
700 if (group.children.length > 0) {
701 dropdown.append(group);
702 }
703 }
704
705 const selectedProfile = profiles.find((p) => p.id === initialSelectedProfileId);
706 if (selectedProfile) {
707 dropdown.val(selectedProfile.id);
708 }
709
710 context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_CREATED, async (profile) => {
711 const isSupported = this.isProfileSupported(profile);
712 if (!isSupported) {
713 return;
714 }
715
716 const group = groups[CONNECT_API_MAP[profile.api].selected];
717 const option = document.createElement('option');
718 option.value = profile.id;
719 option.textContent = profile.name;
720 group.appendChild(option);
721
722 await onCreate(profile);
723 });
724
725 context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_UPDATED, async (oldProfile, newProfile) => {
726 const currentSelected = dropdown.val();
727 const isSelectedProfile = currentSelected === oldProfile.id;
728 await unUpdate(oldProfile, newProfile);
729
730 if (!this.isProfileSupported(newProfile)) {
731 if (isSelectedProfile) {
732 dropdown.val('');
733 dropdown.trigger('change');
734 }
735 return;
736 }
737
738 const group = groups[CONNECT_API_MAP[newProfile.api].selected];
739 const oldOption = group.querySelector(`option[value="${oldProfile.id}"]`);
740 if (oldOption) {
741 oldOption.remove();
742 }
743
744 const option = document.createElement('option');
745 option.value = newProfile.id;
746 option.textContent = newProfile.name;
747 group.appendChild(option);
748
749 if (isSelectedProfile) {
750 // Ackchyually, we don't need to reselect but what if id changes? It is not possible for now I couldn't stop myself.
751 dropdown.val(newProfile.id);
752 dropdown.trigger('change');
753 }
754 });
755
756 context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_DELETED, async (profile) => {
757 const currentSelected = dropdown.val();
758 const isSelectedProfile = currentSelected === profile.id;
759 if (!this.isProfileSupported(profile)) {
760 return;
761 }
762
763 const group = groups[CONNECT_API_MAP[profile.api].selected];
764 const optionToRemove = group.querySelector(`option[value="${profile.id}"]`);
765 if (optionToRemove) {
766 optionToRemove.remove();
767 }
768
769 if (isSelectedProfile) {
770 dropdown.val('');
771 dropdown.trigger('change');
772 }
773
774 await onDelete(profile);
775 });
776
777 dropdown.on('change', async () => {
778 const profileId = dropdown.val();
779 const profile = context.extensionSettings.connectionManager.profiles.find((p) => p.id === profileId);
780 await onChange(profile);
781 });
782 }
783}