Merge branch 'staging' into disk-cache
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | name: 🔀 Pull Request Manager |
| 2 | 2 | |
| 3 | 3 | on: |
| 4 | + workflow_dispatch: # Allow to manually call this workflow | |
| 4 | 5 | pull_request_target: |
| 5 | 6 | types: [opened, synchronize, reopened, edited, labeled, unlabeled, closed] |
| 6 | 7 | pull_request_review_comment: |
| @@ -17,6 +18,12 @@ jobs: | ||
| 17 | 18 | # Only needs to run when code is changed |
| 18 | 19 | if: github.event.action == 'opened' || github.event.action == 'synchronize' |
| 19 | 20 | |
| 21 | + # Override permissions, linter likely needs write access to issues | |
| 22 | + permissions: | |
| 23 | + contents: read | |
| 24 | + issues: write | |
| 25 | + pull-requests: write | |
| 26 | + | |
| 20 | 27 | steps: |
| 21 | 28 | - name: Checkout Repository |
| 22 | 29 | # Checkout |
| @@ -2069,8 +2069,9 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san | ||
| 2069 | 2069 | } |
| 2070 | 2070 | |
| 2071 | 2071 | // Prompt bias replacement should be applied on the raw message |
| 2072 | - if (!power_user.show_user_prompt_bias && ch_name && !isUser && !isSystem) { | |
| 2072 | + const replacedPromptBias = power_user.user_prompt_bias && substituteParams(power_user.user_prompt_bias); | |
| 2073 | - mes = mes.replaceAll(substituteParams(power_user.user_prompt_bias), ''); | |
| 2073 | + if (!power_user.show_user_prompt_bias && ch_name && !isUser && !isSystem && replacedPromptBias && mes.startsWith(replacedPromptBias)) { | |
| 2074 | + mes = mes.slice(replacedPromptBias.length); | |
| 2074 | 2075 | } |
| 2075 | 2076 | |
| 2076 | 2077 | if (!isSystem) { |
| @@ -3,10 +3,13 @@ import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../sc | ||
| 3 | 3 | import { getTextGenServer } from './textgen-settings.js'; |
| 4 | 4 | import { extractReasoningFromData } from './reasoning.js'; |
| 5 | 5 | import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types } from './instruct-mode.js'; |
| 6 | +import { getStreamingReply, tryParseStreamingError } from './openai.js'; | |
| 7 | +import EventSourceStream from './sse-stream.js'; | |
| 6 | 8 | |
| 7 | 9 | // #region Type Definitions |
| 8 | 10 | /** |
| 9 | 11 | * @typedef {Object} TextCompletionRequestBase |
| 12 | + * @property {boolean?} [stream=false] - Whether to stream the response | |
| 10 | 13 | * @property {number} max_tokens - Maximum number of tokens to generate |
| 11 | 14 | * @property {string} [model] - Optional model name |
| 12 | 15 | * @property {string} api_type - Type of API to use |
| @@ -17,6 +20,7 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types | ||
| 17 | 20 | |
| 18 | 21 | /** |
| 19 | 22 | * @typedef {Object} TextCompletionPayloadBase |
| 23 | + * @property {boolean?} [stream=false] - Whether to stream the response | |
| 20 | 24 | * @property {string} prompt - The text prompt for completion |
| 21 | 25 | * @property {number} max_tokens - Maximum number of tokens to generate |
| 22 | 26 | * @property {number} max_new_tokens - Alias for max_tokens |
| @@ -36,6 +40,7 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types | ||
| 36 | 40 | |
| 37 | 41 | /** |
| 38 | 42 | * @typedef {Object} ChatCompletionPayloadBase |
| 43 | + * @property {boolean?} [stream=false] - Whether to stream the response | |
| 39 | 44 | * @property {ChatCompletionMessage[]} messages - Array of chat messages |
| 40 | 45 | * @property {string} [model] - Optional model name to use for completion |
| 41 | 46 | * @property {string} chat_completion_source - Source provider for chat completion |
| @@ -52,10 +57,19 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types | ||
| 52 | 57 | * @property {string} reasoning - Extracted reasoning. |
| 53 | 58 | */ |
| 54 | 59 | |
| 60 | +/** | |
| 61 | + * @typedef {Object} StreamResponse | |
| 62 | + * @property {string} text - Generated text. | |
| 63 | + * @property {string[]} swipes - Generated swipes | |
| 64 | + * @property {Object} state - Generated state | |
| 65 | + * @property {string?} [state.reasoning] - Generated reasoning | |
| 66 | + * @property {string?} [state.image] - Generated image | |
| 67 | + */ | |
| 68 | + | |
| 55 | 69 | // #endregion |
| 56 | 70 | |
| 57 | 71 | /** |
| 58 | 72 | * Creates & sends a text completion request. Streaming is not supported. |
| 59 | 73 | */ |
| 60 | 74 | export class TextCompletionService { |
| 61 | 75 | static TYPE = 'textgenerationwebui'; |
| @@ -64,9 +78,10 @@ export class TextCompletionService { | ||
| 64 | 78 | * @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom |
| 65 | 79 | * @returns {TextCompletionPayload} |
| 66 | 80 | */ |
| 67 | 81 | static createRequestData({ stream = false, prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) { |
| 68 | 82 | const payload = { |
| 69 | 83 | ...props, |
| 84 | + stream, | |
| 70 | 85 | prompt, |
| 71 | 86 | max_tokens, |
| 72 | 87 | max_new_tokens: max_tokens, |
| @@ -75,7 +90,6 @@ export class TextCompletionService { | ||
| 75 | 90 | api_server: api_server ?? getTextGenServer(api_type), |
| 76 | 91 | temperature, |
| 77 | 92 | min_p, |
| 78 | - stream: false, | |
| 79 | 93 | }; |
| 80 | 94 | |
| 81 | 95 | // Remove undefined values to avoid API errors |
| @@ -92,34 +106,81 @@ export class TextCompletionService { | ||
| 92 | 106 | * Sends a text completion request to the specified server |
| 93 | 107 | * @param {TextCompletionPayload} data Request data |
| 94 | 108 | * @param {boolean?} extractData Extract message from the response. Default true |
| 95 | - * @returns {Promise<ExtractedData | any>} Extracted data or the raw response | |
| 109 | + * @param {AbortSignal?} signal | |
| 110 | + * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator | |
| 96 | 111 | * @throws {Error} |
| 97 | 112 | */ |
| 98 | 113 | static async sendRequest(data, extractData = true, signal = null) { |
| 99 | - const response = await fetch(getGenerateUrl(this.TYPE), { | |
| 114 | + if (!data.stream) { | |
| 115 | + const response = await fetch(getGenerateUrl(this.TYPE), { | |
| 116 | + method: 'POST', | |
| 117 | + headers: getRequestHeaders(), | |
| 118 | + cache: 'no-cache', | |
| 119 | + body: JSON.stringify(data), | |
| 120 | + signal: signal ?? new AbortController().signal, | |
| 121 | + }); | |
| 122 | + | |
| 123 | + const json = await response.json(); | |
| 124 | + if (!response.ok || json.error) { | |
| 125 | + throw json; | |
| 126 | + } | |
| 127 | + | |
| 128 | + if (!extractData) { | |
| 129 | + return json; | |
| 130 | + } | |
| 131 | + | |
| 132 | + return { | |
| 133 | + content: extractMessageFromData(json, this.TYPE), | |
| 134 | + reasoning: extractReasoningFromData(json, { | |
| 135 | + mainApi: this.TYPE, | |
| 136 | + textGenType: data.api_type, | |
| 137 | + ignoreShowThoughts: true, | |
| 138 | + }), | |
| 139 | + }; | |
| 140 | + } | |
| 141 | + | |
| 142 | + const response = await fetch('/api/backends/text-completions/generate', { | |
| 100 | 143 | method: 'POST', |
| 101 | 144 | headers: getRequestHeaders(), |
| 102 | 145 | cache: 'no-cache', |
| 103 | 146 | body: JSON.stringify(data), |
| 104 | 147 | signal: signal ?? new AbortController().signal, |
| 105 | 148 | }); |
| 106 | 149 | |
| 107 | - const json = await response.json(); | |
| 150 | + if (!response.ok) { | |
| 108 | - if (!response.ok || json.error) { | |
| 151 | + const text = await response.text(); | |
| 109 | - throw json; | |
| 152 | + tryParseStreamingError(response, text, { quiet: true }); | |
| 110 | - } | |
| 111 | 153 | |
| 112 | - if (!extractData) { | |
| 154 | + throw new Error(`Got response status ${response.status}`); | |
| 113 | - return json; | |
| 114 | 155 | } |
| 115 | 156 | |
| 116 | - return { | |
| 157 | + const eventStream = new EventSourceStream(); | |
| 117 | - content: extractMessageFromData(json, this.TYPE), | |
| 158 | + response.body.pipeThrough(eventStream); | |
| 118 | - reasoning: extractReasoningFromData(json, { | |
| 159 | + const reader = eventStream.readable.getReader(); | |
| 119 | - mainApi: this.TYPE, | |
| 160 | + return async function* streamData() { | |
| 120 | - textGenType: data.api_type, | |
| 161 | + let text = ''; | |
| 121 | - ignoreShowThoughts: true, | |
| 162 | + const swipes = []; | |
| 122 | - }), | |
| 163 | + const state = { reasoning: '' }; | |
| 164 | + while (true) { | |
| 165 | + const { done, value } = await reader.read(); | |
| 166 | + if (done) return; | |
| 167 | + if (value.data === '[DONE]') return; | |
| 168 | + | |
| 169 | + tryParseStreamingError(response, value.data, { quiet: true }); | |
| 170 | + | |
| 171 | + let data = JSON.parse(value.data); | |
| 172 | + | |
| 173 | + if (data?.choices?.[0]?.index > 0) { | |
| 174 | + const swipeIndex = data.choices[0].index - 1; | |
| 175 | + swipes[swipeIndex] = (swipes[swipeIndex] || '') + data.choices[0].text; | |
| 176 | + } else { | |
| 177 | + const newText = data?.choices?.[0]?.text || data?.content || ''; | |
| 178 | + text += newText; | |
| 179 | + state.reasoning += data?.choices?.[0]?.reasoning ?? ''; | |
| 180 | + } | |
| 181 | + | |
| 182 | + yield { text, swipes, state }; | |
| 183 | + } | |
| 123 | 184 | }; |
| 124 | 185 | } |
| 125 | 186 | |
| @@ -130,13 +191,15 @@ export class TextCompletionService { | ||
| 130 | 191 | * @param {string?} [options.presetName] - Name of the preset to use for generation settings |
| 131 | 192 | * @param {string?} [options.instructName] - Name of instruct preset for message formatting |
| 132 | 193 | * @param {boolean} extractData - Whether to extract structured data from response |
| 133 | - * @returns {Promise<ExtractedData | any>} Extracted data or the raw response | |
| 194 | + * @param {AbortSignal?} [signal] | |
| 195 | + * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator | |
| 134 | 196 | * @throws {Error} |
| 135 | 197 | */ |
| 136 | 198 | static async processRequest( |
| 137 | 199 | custom, |
| 138 | 200 | options = {}, |
| 139 | 201 | extractData = true, |
| 202 | + signal = null, | |
| 140 | 203 | ) { |
| 141 | 204 | const { presetName, instructName } = options; |
| 142 | 205 | let requestData = { ...custom }; |
| @@ -220,7 +283,7 @@ export class TextCompletionService { | ||
| 220 | 283 | // @ts-ignore |
| 221 | 284 | const data = this.createRequestData(requestData); |
| 222 | 285 | |
| 223 | 286 | return await this.sendRequest(data, extractData, signal); |
| 224 | 287 | } |
| 225 | 288 | |
| 226 | 289 | /** |
| @@ -256,7 +319,7 @@ export class TextCompletionService { | ||
| 256 | 319 | } |
| 257 | 320 | |
| 258 | 321 | /** |
| 259 | 322 | * Creates & sends a chat completion request. Streaming is not supported. |
| 260 | 323 | */ |
| 261 | 324 | export class ChatCompletionService { |
| 262 | 325 | static TYPE = 'openai'; |
| @@ -265,16 +328,16 @@ export class ChatCompletionService { | ||
| 265 | 328 | * @param {ChatCompletionPayload} custom |
| 266 | 329 | * @returns {ChatCompletionPayload} |
| 267 | 330 | */ |
| 268 | 331 | static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, ...props }) { |
| 269 | 332 | const payload = { |
| 270 | 333 | ...props, |
| 334 | + stream, | |
| 271 | 335 | messages, |
| 272 | 336 | model, |
| 273 | 337 | chat_completion_source, |
| 274 | 338 | max_tokens, |
| 275 | 339 | temperature, |
| 276 | 340 | custom_url, |
| 277 | - stream: false, | |
| 278 | 341 | }; |
| 279 | 342 | |
| 280 | 343 | // Remove undefined values to avoid API errors |
| @@ -291,34 +354,74 @@ export class ChatCompletionService { | ||
| 291 | 354 | * Sends a chat completion request |
| 292 | 355 | * @param {ChatCompletionPayload} data Request data |
| 293 | 356 | * @param {boolean?} extractData Extract message from the response. Default true |
| 294 | - * @returns {Promise<ExtractedData | any>} Extracted data or the raw response | |
| 357 | + * @param {AbortSignal?} signal Abort signal | |
| 358 | + * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator | |
| 295 | 359 | * @throws {Error} |
| 296 | 360 | */ |
| 297 | 361 | static async sendRequest(data, extractData = true, signal = null) { |
| 298 | 362 | const response = await fetch('/api/backends/chat-completions/generate', { |
| 299 | 363 | method: 'POST', |
| 300 | 364 | headers: getRequestHeaders(), |
| 301 | 365 | cache: 'no-cache', |
| 302 | 366 | body: JSON.stringify(data), |
| 303 | 367 | signal: signal ?? new AbortController().signal, |
| 304 | 368 | }); |
| 305 | 369 | |
| 306 | - const json = await response.json(); | |
| 370 | + if (!data.stream) { | |
| 307 | - if (!response.ok || json.error) { | |
| 371 | + const json = await response.json(); | |
| 308 | - throw json; | |
| 372 | + if (!response.ok || json.error) { | |
| 373 | + throw json; | |
| 374 | + } | |
| 375 | + | |
| 376 | + if (!extractData) { | |
| 377 | + return json; | |
| 378 | + } | |
| 379 | + | |
| 380 | + return { | |
| 381 | + content: extractMessageFromData(json, this.TYPE), | |
| 382 | + reasoning: extractReasoningFromData(json, { | |
| 383 | + mainApi: this.TYPE, | |
| 384 | + textGenType: data.chat_completion_source, | |
| 385 | + ignoreShowThoughts: true, | |
| 386 | + }), | |
| 387 | + }; | |
| 309 | 388 | } |
| 310 | 389 | |
| 311 | 390 | if (!extractDataresponse.ok) { |
| 312 | - return json; | |
| 391 | + const text = await response.text(); | |
| 392 | + tryParseStreamingError(response, text, { quiet: true }); | |
| 393 | + | |
| 394 | + throw new Error(`Got response status ${response.status}`); | |
| 313 | 395 | } |
| 314 | 396 | |
| 315 | - return { | |
| 397 | + const eventStream = new EventSourceStream(); | |
| 316 | - content: extractMessageFromData(json, this.TYPE), | |
| 398 | + response.body.pipeThrough(eventStream); | |
| 317 | - reasoning: extractReasoningFromData(json, { | |
| 399 | + const reader = eventStream.readable.getReader(); | |
| 318 | - mainApi: this.TYPE, | |
| 400 | + return async function* streamData() { | |
| 319 | - textGenType: data.chat_completion_source, | |
| 401 | + let text = ''; | |
| 320 | - ignoreShowThoughts: true, | |
| 402 | + const swipes = []; | |
| 321 | - }), | |
| 403 | + const state = { reasoning: '', image: '' }; | |
| 404 | + while (true) { | |
| 405 | + const { done, value } = await reader.read(); | |
| 406 | + if (done) return; | |
| 407 | + const rawData = value.data; | |
| 408 | + if (rawData === '[DONE]') return; | |
| 409 | + tryParseStreamingError(response, rawData, { quiet: true }); | |
| 410 | + const parsed = JSON.parse(rawData); | |
| 411 | + | |
| 412 | + const reply = getStreamingReply(parsed, state, { | |
| 413 | + chatCompletionSource: data.chat_completion_source, | |
| 414 | + overrideShowThoughts: true, | |
| 415 | + }); | |
| 416 | + if (Array.isArray(parsed?.choices) && parsed?.choices?.[0]?.index > 0) { | |
| 417 | + const swipeIndex = parsed.choices[0].index - 1; | |
| 418 | + swipes[swipeIndex] = (swipes[swipeIndex] || '') + reply; | |
| 419 | + } else { | |
| 420 | + text += reply; | |
| 421 | + } | |
| 422 | + | |
| 423 | + yield { text, swipes: swipes, state }; | |
| 424 | + } | |
| 322 | 425 | }; |
| 323 | 426 | } |
| 324 | 427 | |
| @@ -327,11 +430,12 @@ export class ChatCompletionService { | ||
| 327 | 430 | * @param {ChatCompletionPayload} custom |
| 328 | 431 | * @param {Object} options - Configuration options |
| 329 | 432 | * @param {string?} [options.presetName] - Name of the preset to use for generation settings |
| 330 | 433 | * @param {boolean} [extractData=true] - Whether to extract structured data from response |
| 331 | - * @returns {Promise<ExtractedData | any>} Extracted data or the raw response | |
| 434 | + * @param {AbortSignal?} [signal] - Abort signal | |
| 435 | + * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator | |
| 332 | 436 | * @throws {Error} |
| 333 | 437 | */ |
| 334 | 438 | static async processRequest(custom, options, extractData = true, signal = null) { |
| 335 | 439 | const { presetName } = options; |
| 336 | 440 | let requestData = { ...custom }; |
| 337 | 441 | |
| @@ -354,7 +458,7 @@ export class ChatCompletionService { | ||
| 354 | 458 | |
| 355 | 459 | const data = this.createRequestData(requestData); |
| 356 | 460 | |
| 357 | 461 | return await this.sendRequest(data, extractData, signal); |
| 358 | 462 | } |
| 359 | 463 | |
| 360 | 464 | /** |
| @@ -276,10 +276,12 @@ export async function getWebLlmContextSize() { | ||
| 276 | 276 | } |
| 277 | 277 | |
| 278 | 278 | /** |
| 279 | 279 | * It uses the profiles to send a generate request to the API. Doesn't support streaming. |
| 280 | 280 | */ |
| 281 | 281 | export class ConnectionManagerRequestService { |
| 282 | 282 | static defaultSendRequestParams = { |
| 283 | + stream: false, | |
| 284 | + signal: null, | |
| 283 | 285 | extractData: true, |
| 284 | 286 | includePreset: true, |
| 285 | 287 | includeInstruct: true, |
| @@ -296,11 +298,11 @@ export class ConnectionManagerRequestService { | ||
| 296 | 298 | * @param {string} profileId |
| 297 | 299 | * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt |
| 298 | 300 | * @param {number} maxTokens |
| 299 | 301 | * @param {{stream?: boolean, signal?: AbortSignal, extractData?: boolean, includePreset?: boolean, includeInstruct?: boolean}} custom - default values are true |
| 300 | 302 | * @returns {Promise<import('../custom-request.js').ExtractedData | any(() => AsyncGenerator<import('../custom-request.js').StreamResponse>)>} ExtractedIf not streaming, returns extracted data; orif thestreaming, rawreturns responsea function that creates an AsyncGenerator |
| 301 | 303 | */ |
| 302 | 304 | static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) { |
| 303 | 305 | const { stream, signal, extractData, includePreset, includeInstruct } = { ...this.defaultSendRequestParams, ...custom }; |
| 304 | 306 | |
| 305 | 307 | const context = SillyTavern.getContext(); |
| 306 | 308 | if (context.extensionSettings.disabledExtensions.includes('connection-manager')) { |
| @@ -319,6 +321,7 @@ export class ConnectionManagerRequestService { | ||
| 319 | 321 | |
| 320 | 322 | const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }]; |
| 321 | 323 | return await context.ChatCompletionService.processRequest({ |
| 324 | + stream, | |
| 322 | 325 | messages, |
| 323 | 326 | max_tokens: maxTokens, |
| 324 | 327 | model: profile.model, |
| @@ -326,7 +329,7 @@ export class ConnectionManagerRequestService { | ||
| 326 | 329 | custom_url: profile['api-url'], |
| 327 | 330 | }, { |
| 328 | 331 | presetName: includePreset ? profile.preset : undefined, |
| 329 | 332 | }, extractData, signal); |
| 330 | 333 | } |
| 331 | 334 | case 'textgenerationwebui': { |
| 332 | 335 | if (!selectedApiMap.type) { |
| @@ -334,6 +337,7 @@ export class ConnectionManagerRequestService { | ||
| 334 | 337 | } |
| 335 | 338 | |
| 336 | 339 | return await context.TextCompletionService.processRequest({ |
| 340 | + stream, | |
| 337 | 341 | prompt, |
| 338 | 342 | max_tokens: maxTokens, |
| 339 | 343 | model: profile.model, |
| @@ -342,7 +346,7 @@ export class ConnectionManagerRequestService { | ||
| 342 | 346 | }, { |
| 343 | 347 | instructName: includeInstruct ? profile.instruct : undefined, |
| 344 | 348 | presetName: includePreset ? profile.preset : undefined, |
| 345 | 349 | }, extractData, signal); |
| 346 | 350 | } |
| 347 | 351 | default: { |
| 348 | 352 | throw new Error(`Unknown API type ${selectedApiMap.selected}`); |
| @@ -882,7 +882,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) { | ||
| 882 | 882 | activatedMembers = activateListOrder(enabledMembers); |
| 883 | 883 | } |
| 884 | 884 | else if (activationStrategy === group_activation_strategy.POOLED) { |
| 885 | 885 | activatedMembers = activatePooledOrder(enabledMembers, lastMessage, isUserInput); |
| 886 | 886 | } |
| 887 | 887 | else if (activationStrategy === group_activation_strategy.MANUAL && !isUserInput) { |
| 888 | 888 | activatedMembers = shuffle(enabledMembers).slice(0, 1).map(x => characters.findIndex(y => y.avatar === x)).filter(x => x !== -1); |
| @@ -1030,16 +1030,17 @@ function activateListOrder(members) { | ||
| 1030 | 1030 | * Activate group members based on the last message. |
| 1031 | 1031 | * @param {string[]} members List of member avatars |
| 1032 | 1032 | * @param {Object} lastMessage Last message |
| 1033 | + * @param {boolean} isUserInput Whether the user has input text | |
| 1033 | 1034 | * @returns {number[]} List of character ids |
| 1034 | 1035 | */ |
| 1035 | 1036 | function activatePooledOrder(members, lastMessage, isUserInput) { |
| 1036 | 1037 | /** @type {string} */ |
| 1037 | 1038 | let activatedMember = null; |
| 1038 | 1039 | /** @type {string[]} */ |
| 1039 | 1040 | const spokenSinceUser = []; |
| 1040 | 1041 | |
| 1041 | 1042 | for (const message of chat.slice().reverse()) { |
| 1042 | 1043 | if (message.is_user || isUserInput) { |
| 1043 | 1044 | break; |
| 1044 | 1045 | } |
| 1045 | 1046 | |
| @@ -1444,8 +1444,10 @@ export async function prepareOpenAIMessages({ | ||
| 1444 | 1444 | * Handles errors during streaming requests. |
| 1445 | 1445 | * @param {Response} response |
| 1446 | 1446 | * @param {string} decoded - response text or decoded stream data |
| 1447 | + * @param {object} [options] | |
| 1448 | + * @param {boolean?} [options.quiet=false] Suppress toast messages | |
| 1447 | 1449 | */ |
| 1448 | 1450 | export function tryParseStreamingError(response, decoded, { quiet = false } = {}) { |
| 1449 | 1451 | try { |
| 1450 | 1452 | const data = JSON.parse(decoded); |
| 1451 | 1453 | |
| @@ -1453,19 +1455,19 @@ function tryParseStreamingError(response, decoded) { | ||
| 1453 | 1455 | return; |
| 1454 | 1456 | } |
| 1455 | 1457 | |
| 1456 | 1458 | checkQuotaError(data, { quiet }); |
| 1457 | 1459 | checkModerationError(data, { quiet }); |
| 1458 | 1460 | |
| 1459 | 1461 | // these do not throw correctly (equiv to Error("[object Object]")) |
| 1460 | 1462 | // if trying to fix "[object Object]" displayed to users, start here |
| 1461 | 1463 | |
| 1462 | 1464 | if (data.error) { |
| 1463 | 1465 | !quiet && toastr.error(data.error.message || response.statusText, 'Chat Completion API'); |
| 1464 | 1466 | throw new Error(data); |
| 1465 | 1467 | } |
| 1466 | 1468 | |
| 1467 | 1469 | if (data.message) { |
| 1468 | 1470 | !quiet && toastr.error(data.message, 'Chat Completion API'); |
| 1469 | 1471 | throw new Error(data); |
| 1470 | 1472 | } |
| 1471 | 1473 | } |
| @@ -1477,16 +1479,18 @@ function tryParseStreamingError(response, decoded) { | ||
| 1477 | 1479 | /** |
| 1478 | 1480 | * Checks if the response contains a quota error and displays a popup if it does. |
| 1479 | 1481 | * @param data |
| 1482 | + * @param {object} [options] | |
| 1483 | + * @param {boolean?} [options.quiet=false] Suppress toast messages | |
| 1480 | 1484 | * @returns {void} |
| 1481 | 1485 | * @throws {object} - response JSON |
| 1482 | 1486 | */ |
| 1483 | 1487 | function checkQuotaError(data, { quiet = false } = {}) { |
| 1484 | 1488 | if (!data) { |
| 1485 | 1489 | return; |
| 1486 | 1490 | } |
| 1487 | 1491 | |
| 1488 | 1492 | if (data.quota_error) { |
| 1489 | 1493 | !quiet && renderTemplateAsync('quotaError').then((html) => Popup.show.text('Quota Error', html)); |
| 1490 | 1494 | |
| 1491 | 1495 | // this does not throw correctly (equiv to Error("[object Object]")) |
| 1492 | 1496 | // if trying to fix "[object Object]" displayed to users, start here |
| @@ -1494,9 +1498,14 @@ function checkQuotaError(data) { | ||
| 1494 | 1498 | } |
| 1495 | 1499 | } |
| 1496 | 1500 | |
| 1497 | -function checkModerationError(data) { | |
| 1501 | +/** | |
| 1502 | + * @param {any} data | |
| 1503 | + * @param {object} [options] | |
| 1504 | + * @param {boolean?} [options.quiet=false] Suppress toast messages | |
| 1505 | + */ | |
| 1506 | +function checkModerationError(data, { quiet = false } = {}) { | |
| 1498 | 1507 | const moderationError = data?.error?.message?.includes('requires moderation'); |
| 1499 | 1508 | if (moderationError && !quiet) { |
| 1500 | 1509 | const moderationReason = `Reasons: ${data?.error?.metadata?.reasons?.join(', ') ?? '(N/A)'}`; |
| 1501 | 1510 | const flaggedText = data?.error?.metadata?.flagged_input ?? '(N/A)'; |
| 1502 | 1511 | toastr.info(flaggedText, moderationReason, { timeOut: 10000 }); |
| @@ -2255,37 +2264,43 @@ async function sendOpenAIRequest(type, messages, signal) { | ||
| 2255 | 2264 | * Extracts the reply from the response data from a chat completions-like source |
| 2256 | 2265 | * @param {object} data Response data from the chat completions-like source |
| 2257 | 2266 | * @param {object} state Additional state to keep track of |
| 2267 | + * @param {object} [options] Additional options | |
| 2268 | + * @param {string?} [options.chatCompletionSource] Chat completion source | |
| 2269 | + * @param {boolean?} [options.overrideShowThoughts] Override show thoughts | |
| 2258 | 2270 | * @returns {string} The reply extracted from the response data |
| 2259 | 2271 | */ |
| 2260 | -function getStreamingReply(data, state) { | |
| 2272 | +export function getStreamingReply(data, state, { chatCompletionSource = null, overrideShowThoughts = null } = {}) { | |
| 2261 | - if (oai_settings.chat_completion_source === chat_completion_sources.CLAUDE) { | |
| 2273 | + const chat_completion_source = chatCompletionSource ?? oai_settings.chat_completion_source; | |
| 2262 | - if (oai_settings.show_thoughts) { | |
| 2274 | + const show_thoughts = overrideShowThoughts ?? oai_settings.show_thoughts; | |
| 2275 | + | |
| 2276 | + if (chat_completion_source === chat_completion_sources.CLAUDE) { | |
| 2277 | + if (show_thoughts) { | |
| 2263 | 2278 | state.reasoning += data?.delta?.thinking || ''; |
| 2264 | 2279 | } |
| 2265 | 2280 | return data?.delta?.text || ''; |
| 2266 | 2281 | } else if (oai_settings.chat_completion_source === chat_completion_sources.MAKERSUITE) { |
| 2267 | 2282 | const inlineData = data?.candidates?.[0]?.content?.parts?.find(x => x.inlineData)?.inlineData; |
| 2268 | 2283 | if (inlineData) { |
| 2269 | 2284 | state.image = `data:${inlineData.mimeType};base64,${inlineData.data}`; |
| 2270 | 2285 | } |
| 2271 | 2286 | if (oai_settings.show_thoughts) { |
| 2272 | 2287 | state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || ''); |
| 2273 | 2288 | } |
| 2274 | 2289 | return data?.candidates?.[0]?.content?.parts?.filter(x => !x.thought)?.map(x => x.text)?.[0] || ''; |
| 2275 | 2290 | } else if (oai_settings.chat_completion_source === chat_completion_sources.COHERE) { |
| 2276 | 2291 | return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || ''; |
| 2277 | 2292 | } else if (oai_settings.chat_completion_source === chat_completion_sources.DEEPSEEK) { |
| 2278 | 2293 | if (oai_settings.show_thoughts) { |
| 2279 | 2294 | state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content || ''); |
| 2280 | 2295 | } |
| 2281 | 2296 | return data.choices?.[0]?.delta?.content || ''; |
| 2282 | 2297 | } else if (oai_settings.chat_completion_source === chat_completion_sources.OPENROUTER) { |
| 2283 | 2298 | if (oai_settings.show_thoughts) { |
| 2284 | 2299 | state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || ''); |
| 2285 | 2300 | } |
| 2286 | 2301 | return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? ''; |
| 2287 | 2302 | } else if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) { |
| 2288 | 2303 | if (oai_settings.show_thoughts) { |
| 2289 | 2304 | state.reasoning += |
| 2290 | 2305 | data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ?? |
| 2291 | 2306 | data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ?? |
| @@ -69,7 +69,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js'; | ||
| 69 | 69 | import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js'; |
| 70 | 70 | import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js'; |
| 71 | 71 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; |
| 72 | 72 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 73 | 73 | import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 74 | 74 | import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js'; |
| 75 | 75 | import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js'; |
| @@ -77,6 +77,7 @@ import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHel | ||
| 77 | 77 | import { accountStorage } from './util/AccountStorage.js'; |
| 78 | 78 | import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js'; |
| 79 | 79 | import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; |
| 80 | +import { t } from './i18n.js'; | |
| 80 | 81 | export { |
| 81 | 82 | executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand, |
| 82 | 83 | }; |
| @@ -1556,16 +1557,28 @@ export function initDefaultSlashCommands() { | ||
| 1556 | 1557 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1557 | 1558 | name: 'buttons', |
| 1558 | 1559 | callback: buttonsCallback, |
| 1559 | 1560 | returns: 'clicked button label (or array of labels if multiple is enabled)', |
| 1560 | 1561 | namedArgumentList: [ |
| 1561 | 1562 | new SlashCommandNamedArgument.fromProps({ |
| 1562 | - 'labels', 'button labels', [ARGUMENT_TYPE.LIST], true, | |
| 1563 | + name: 'labels', | |
| 1563 | - ), | |
| 1564 | + description: 'button labels', | |
| 1565 | + typeList: [ARGUMENT_TYPE.LIST], | |
| 1566 | + isRequired: true, | |
| 1567 | + }), | |
| 1568 | + SlashCommandNamedArgument.fromProps({ | |
| 1569 | + name: 'multiple', | |
| 1570 | + description: 'if enabled multiple buttons can be clicked/toggled, and all clicked buttons are returned as an array', | |
| 1571 | + typeList: [ARGUMENT_TYPE.BOOLEAN], | |
| 1572 | + enumList: commonEnumProviders.boolean('trueFalse')(), | |
| 1573 | + defaultValue: 'false', | |
| 1574 | + }), | |
| 1564 | 1575 | ], |
| 1565 | 1576 | unnamedArgumentList: [ |
| 1566 | 1577 | new SlashCommandArgument.fromProps({ |
| 1567 | - 'text', [ARGUMENT_TYPE.STRING], true, | |
| 1578 | + description: 'text', | |
| 1568 | - ), | |
| 1579 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1580 | + isRequired: true, | |
| 1581 | + }), | |
| 1569 | 1582 | ], |
| 1570 | 1583 | helpString: ` |
| 1571 | 1584 | <div> |
| @@ -2377,6 +2390,18 @@ async function trimTokensCallback(arg, value) { | ||
| 2377 | 2390 | } |
| 2378 | 2391 | } |
| 2379 | 2392 | |
| 2393 | +/** | |
| 2394 | + * Displays a popup with buttons based on provided labels and handles button interactions. | |
| 2395 | + * | |
| 2396 | + * @param {object} args - Named arguments for the command | |
| 2397 | + * @param {string} args.labels - JSON string of an array of button labels | |
| 2398 | + * @param {string} [args.multiple=false] - Flag indicating if multiple buttons can be toggled | |
| 2399 | + * @param {string} text - The text content to be displayed within the popup | |
| 2400 | + * | |
| 2401 | + * @returns {Promise<string>} - A promise that resolves to a string of the button labels selected | |
| 2402 | + * If 'multiple' is true, returns a JSON string array of labels. | |
| 2403 | + * If 'multiple' is false, returns a single label string. | |
| 2404 | + */ | |
| 2380 | 2405 | async function buttonsCallback(args, text) { |
| 2381 | 2406 | try { |
| 2382 | 2407 | /** @type {string[]} */ |
| @@ -2387,6 +2412,10 @@ async function buttonsCallback(args, text) { | ||
| 2387 | 2412 | return ''; |
| 2388 | 2413 | } |
| 2389 | 2414 | |
| 2415 | + /** @type {Set<number>} */ | |
| 2416 | + const multipleToggledState = new Set(); | |
| 2417 | + const multiple = isTrueBoolean(args?.multiple); | |
| 2418 | + | |
| 2390 | 2419 | // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel |
| 2391 | 2420 | const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button])); |
| 2392 | 2421 | |
| @@ -2404,11 +2433,24 @@ async function buttonsCallback(args, text) { | ||
| 2404 | 2433 | |
| 2405 | 2434 | for (const [result, button] of resultToButtonMap) { |
| 2406 | 2435 | const buttonElement = document.createElement('div'); |
| 2407 | 2436 | buttonElement.classList.add('menu_button', 'result-control', 'wide100p'); |
| 2408 | - buttonElement.dataset.result = String(result); | |
| 2437 | + | |
| 2409 | - buttonElement.addEventListener('click', async () => { | |
| 2438 | + if (multiple) { | |
| 2410 | - await popup.complete(result); | |
| 2439 | + buttonElement.classList.add('toggleable'); | |
| 2411 | - }); | |
| 2440 | + buttonElement.dataset.toggleValue = String(result); | |
| 2441 | + buttonElement.addEventListener('click', async () => { | |
| 2442 | + buttonElement.classList.toggle('toggled'); | |
| 2443 | + if (buttonElement.classList.contains('toggled')) { | |
| 2444 | + multipleToggledState.add(result); | |
| 2445 | + } else { | |
| 2446 | + multipleToggledState.delete(result); | |
| 2447 | + } | |
| 2448 | + }); | |
| 2449 | + } else { | |
| 2450 | + buttonElement.classList.add('result-control'); | |
| 2451 | + buttonElement.dataset.result = String(result); | |
| 2452 | + } | |
| 2453 | + | |
| 2412 | 2454 | buttonElement.innerText = button; |
| 2413 | 2455 | buttonContainer.appendChild(buttonElement); |
| 2414 | 2456 | } |
| @@ -2424,10 +2466,19 @@ async function buttonsCallback(args, text) { | ||
| 2424 | 2466 | popupContainer.style.flexDirection = 'column'; |
| 2425 | 2467 | popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup |
| 2426 | 2468 | |
| 2427 | 2469 | popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'multiple ? t`Ok` : t`Cancel'`, allowVerticalScrolling: true }); |
| 2428 | 2470 | popup.show() |
| 2429 | 2471 | .then((result => resolve(typeof result === 'number' ? resultToButtonMap.getgetResult(result) ?? '' : ''))) |
| 2430 | 2472 | .catch(() => resolve('')); |
| 2473 | + | |
| 2474 | + /** @returns {string} @param {string|number|boolean} result */ | |
| 2475 | + function getResult(result) { | |
| 2476 | + if (multiple) { | |
| 2477 | + const array = result === POPUP_RESULT.AFFIRMATIVE ? Array.from(multipleToggledState).map(r => resultToButtonMap.get(r) ?? '') : []; | |
| 2478 | + return JSON.stringify(array); | |
| 2479 | + } | |
| 2480 | + return typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : ''; | |
| 2481 | + } | |
| 2431 | 2482 | }); |
| 2432 | 2483 | } catch { |
| 2433 | 2484 | return ''; |
| @@ -2911,6 +2911,35 @@ select option:not(:checked) { | ||
| 2911 | 2911 | pointer-events: none; |
| 2912 | 2912 | } |
| 2913 | 2913 | |
| 2914 | +.menu_button.toggleable { | |
| 2915 | + padding-left: 20px; | |
| 2916 | +} | |
| 2917 | + | |
| 2918 | +.menu_button.toggleable.toggled { | |
| 2919 | + border-color: var(--active); | |
| 2920 | +} | |
| 2921 | + | |
| 2922 | +.menu_button.toggleable:not(.toggled) { | |
| 2923 | + filter: brightness(80%); | |
| 2924 | +} | |
| 2925 | + | |
| 2926 | +.menu_button.toggleable::before { | |
| 2927 | + font-family: "Font Awesome 6 Free"; | |
| 2928 | + margin-left: 10px; | |
| 2929 | + position: absolute; | |
| 2930 | + left: 0; | |
| 2931 | +} | |
| 2932 | + | |
| 2933 | +.menu_button.toggleable.toggled::before { | |
| 2934 | + content: "\f00c"; | |
| 2935 | + color: var(--active); | |
| 2936 | +} | |
| 2937 | + | |
| 2938 | +.menu_button.toggleable:not(.toggled)::before { | |
| 2939 | + content: "\f00d"; | |
| 2940 | + color: var(--fullred); | |
| 2941 | +} | |
| 2942 | + | |
| 2914 | 2943 | .fav_on { |
| 2915 | 2944 | color: var(--golden) !important; |
| 2916 | 2945 | } |
| @@ -2921,7 +2950,7 @@ select option:not(:checked) { | ||
| 2921 | 2950 | } |
| 2922 | 2951 | |
| 2923 | 2952 | .menu_button.togglable:not(.toggleEnabled) { |
| 2924 | 2953 | color: redvar(--fullred); |
| 2925 | 2954 | } |
| 2926 | 2955 | |
| 2927 | 2956 | .displayBlock { |