| 1 | import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js'; |
| 2 | import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js'; |
| 3 | import { t } from '../i18n.js'; |
| 4 | import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js'; |
| 5 | import { SECRET_KEYS, secret_state } from '../secrets.js'; |
| 6 | import { textgen_types, textgenerationwebui_settings } from '../textgen-settings.js'; |
| 7 | import { getTokenCountAsync } from '../tokenizers.js'; |
| 8 | import { 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 | */ |
| 16 | export 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 | |
| 161 | function 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 | */ |
| 300 | export 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 | */ |
| 337 | export 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 | */ |
| 355 | export 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 | */ |
| 374 | export 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 | */ |
| 388 | export 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 | } |