Merge branch 'staging' into disk-cache

834c9751f8c71bcb7503263b6b708d7f0061d720

Cohee <18619528+Cohee1207@users.noreply.github.com>

8 files changed, +304 -92Ignore whitespace
.github/workflows/pr-auto-manager.yml+7 -0
@@ -1,6 +1,7 @@
1name: 🔀 Pull Request Manager1name: 🔀 Pull Request Manager
22
3on:3on:
4 workflow_dispatch: # Allow to manually call this workflow
4 pull_request_target:5 pull_request_target:
5 types: [opened, synchronize, reopened, edited, labeled, unlabeled, closed]6 types: [opened, synchronize, reopened, edited, labeled, unlabeled, closed]
6 pull_request_review_comment:7 pull_request_review_comment:
@@ -17,6 +18,12 @@ jobs:
17 # Only needs to run when code is changed18 # Only needs to run when code is changed
18 if: github.event.action == 'opened' || github.event.action == 'synchronize'19 if: github.event.action == 'opened' || github.event.action == 'synchronize'
1920
21 # Override permissions, linter likely needs write access to issues
22 permissions:
23 contents: read
24 issues: write
25 pull-requests: write
26
20 steps:27 steps:
21 - name: Checkout Repository28 - name: Checkout Repository
22 # Checkout29 # Checkout
public/script.js+3 -2
@@ -2069,8 +2069,9 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
2069 }2069 }
20702070
2071 // Prompt bias replacement should be applied on the raw message2071 // 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 }
20752076
2076 if (!isSystem) {2077 if (!isSystem) {
public/scripts/custom-request.js+148 -44
@@ -3,10 +3,13 @@ import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../sc
3import { getTextGenServer } from './textgen-settings.js';3import { getTextGenServer } from './textgen-settings.js';
4import { extractReasoningFromData } from './reasoning.js';4import { extractReasoningFromData } from './reasoning.js';
5import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types } from './instruct-mode.js';5import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types } from './instruct-mode.js';
6import { getStreamingReply, tryParseStreamingError } from './openai.js';
7import EventSourceStream from './sse-stream.js';
68
7// #region Type Definitions9// #region Type Definitions
8/**10/**
9 * @typedef {Object} TextCompletionRequestBase11 * @typedef {Object} TextCompletionRequestBase
12 * @property {boolean?} [stream=false] - Whether to stream the response
10 * @property {number} max_tokens - Maximum number of tokens to generate13 * @property {number} max_tokens - Maximum number of tokens to generate
11 * @property {string} [model] - Optional model name14 * @property {string} [model] - Optional model name
12 * @property {string} api_type - Type of API to use15 * @property {string} api_type - Type of API to use
@@ -17,6 +20,7 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
1720
18/**21/**
19 * @typedef {Object} TextCompletionPayloadBase22 * @typedef {Object} TextCompletionPayloadBase
23 * @property {boolean?} [stream=false] - Whether to stream the response
20 * @property {string} prompt - The text prompt for completion24 * @property {string} prompt - The text prompt for completion
21 * @property {number} max_tokens - Maximum number of tokens to generate25 * @property {number} max_tokens - Maximum number of tokens to generate
22 * @property {number} max_new_tokens - Alias for max_tokens26 * @property {number} max_new_tokens - Alias for max_tokens
@@ -36,6 +40,7 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
3640
37/**41/**
38 * @typedef {Object} ChatCompletionPayloadBase42 * @typedef {Object} ChatCompletionPayloadBase
43 * @property {boolean?} [stream=false] - Whether to stream the response
39 * @property {ChatCompletionMessage[]} messages - Array of chat messages44 * @property {ChatCompletionMessage[]} messages - Array of chat messages
40 * @property {string} [model] - Optional model name to use for completion45 * @property {string} [model] - Optional model name to use for completion
41 * @property {string} chat_completion_source - Source provider for chat completion46 * @property {string} chat_completion_source - Source provider for chat completion
@@ -52,10 +57,19 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
52 * @property {string} reasoning - Extracted reasoning.57 * @property {string} reasoning - Extracted reasoning.
53 */58 */
5459
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// #endregion69// #endregion
5670
57/**71/**
58 * Creates & sends a text completion request. Streaming is not supported.72 * Creates & sends a text completion request.
59 */73 */
60export class TextCompletionService {74export class TextCompletionService {
61 static TYPE = 'textgenerationwebui';75 static TYPE = 'textgenerationwebui';
@@ -64,9 +78,10 @@ export class TextCompletionService {
64 * @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom78 * @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom
65 * @returns {TextCompletionPayload}79 * @returns {TextCompletionPayload}
66 */80 */
67 static createRequestData({ prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {81 static createRequestData({ stream = false, prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {
68 const payload = {82 const payload = {
69 ...props,83 ...props,
84 stream,
70 prompt,85 prompt,
71 max_tokens,86 max_tokens,
72 max_new_tokens: max_tokens,87 max_new_tokens: max_tokens,
@@ -75,7 +90,6 @@ export class TextCompletionService {
75 api_server: api_server ?? getTextGenServer(api_type),90 api_server: api_server ?? getTextGenServer(api_type),
76 temperature,91 temperature,
77 min_p,92 min_p,
78 stream: false,
79 };93 };
8094
81 // Remove undefined values to avoid API errors95 // Remove undefined values to avoid API errors
@@ -92,34 +106,81 @@ export class TextCompletionService {
92 * Sends a text completion request to the specified server106 * Sends a text completion request to the specified server
93 * @param {TextCompletionPayload} data Request data107 * @param {TextCompletionPayload} data Request data
94 * @param {boolean?} extractData Extract message from the response. Default true108 * @param {boolean?} extractData Extract message from the response. Default true
95 * @returns {Promise<ExtractedData | any>} Extracted data or the raw response109 * @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 * @throws {Error}111 * @throws {Error}
97 */112 */
98 static async sendRequest(data, extractData = true) {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 method: 'POST',143 method: 'POST',
101 headers: getRequestHeaders(),144 headers: getRequestHeaders(),
102 cache: 'no-cache',145 cache: 'no-cache',
103 body: JSON.stringify(data),146 body: JSON.stringify(data),
104 signal: new AbortController().signal,147 signal: signal ?? new AbortController().signal,
105 });148 });
106149
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 }
111153
112 if (!extractData) {154 throw new Error(`Got response status ${response.status}`);
113 return json;
114 }155 }
115156
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 }
125186
@@ -130,13 +191,15 @@ export class TextCompletionService {
130 * @param {string?} [options.presetName] - Name of the preset to use for generation settings191 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
131 * @param {string?} [options.instructName] - Name of instruct preset for message formatting192 * @param {string?} [options.instructName] - Name of instruct preset for message formatting
132 * @param {boolean} extractData - Whether to extract structured data from response193 * @param {boolean} extractData - Whether to extract structured data from response
133 * @returns {Promise<ExtractedData | any>} Extracted data or the raw response194 * @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 * @throws {Error}196 * @throws {Error}
135 */197 */
136 static async processRequest(198 static async processRequest(
137 custom,199 custom,
138 options = {},200 options = {},
139 extractData = true,201 extractData = true,
202 signal = null,
140 ) {203 ) {
141 const { presetName, instructName } = options;204 const { presetName, instructName } = options;
142 let requestData = { ...custom };205 let requestData = { ...custom };
@@ -220,7 +283,7 @@ export class TextCompletionService {
220 // @ts-ignore283 // @ts-ignore
221 const data = this.createRequestData(requestData);284 const data = this.createRequestData(requestData);
222285
223 return await this.sendRequest(data, extractData);286 return await this.sendRequest(data, extractData, signal);
224 }287 }
225288
226 /**289 /**
@@ -256,7 +319,7 @@ export class TextCompletionService {
256}319}
257320
258/**321/**
259 * Creates & sends a chat completion request. Streaming is not supported.322 * Creates & sends a chat completion request.
260 */323 */
261export class ChatCompletionService {324export class ChatCompletionService {
262 static TYPE = 'openai';325 static TYPE = 'openai';
@@ -265,16 +328,16 @@ export class ChatCompletionService {
265 * @param {ChatCompletionPayload} custom328 * @param {ChatCompletionPayload} custom
266 * @returns {ChatCompletionPayload}329 * @returns {ChatCompletionPayload}
267 */330 */
268 static createRequestData({ messages, model, chat_completion_source, max_tokens, temperature, custom_url, ...props }) {331 static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, ...props }) {
269 const payload = {332 const payload = {
270 ...props,333 ...props,
334 stream,
271 messages,335 messages,
272 model,336 model,
273 chat_completion_source,337 chat_completion_source,
274 max_tokens,338 max_tokens,
275 temperature,339 temperature,
276 custom_url,340 custom_url,
277 stream: false,
278 };341 };
279342
280 // Remove undefined values to avoid API errors343 // Remove undefined values to avoid API errors
@@ -291,34 +354,74 @@ export class ChatCompletionService {
291 * Sends a chat completion request354 * Sends a chat completion request
292 * @param {ChatCompletionPayload} data Request data355 * @param {ChatCompletionPayload} data Request data
293 * @param {boolean?} extractData Extract message from the response. Default true356 * @param {boolean?} extractData Extract message from the response. Default true
294 * @returns {Promise<ExtractedData | any>} Extracted data or the raw response357 * @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 * @throws {Error}359 * @throws {Error}
296 */360 */
297 static async sendRequest(data, extractData = true) {361 static async sendRequest(data, extractData = true, signal = null) {
298 const response = await fetch('/api/backends/chat-completions/generate', {362 const response = await fetch('/api/backends/chat-completions/generate', {
299 method: 'POST',363 method: 'POST',
300 headers: getRequestHeaders(),364 headers: getRequestHeaders(),
301 cache: 'no-cache',365 cache: 'no-cache',
302 body: JSON.stringify(data),366 body: JSON.stringify(data),
303 signal: new AbortController().signal,367 signal: signal ?? new AbortController().signal,
304 });368 });
305369
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 }
310389
311 if (!extractData) {390 if (!response.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 }
314396
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 }
324427
@@ -327,11 +430,12 @@ export class ChatCompletionService {
327 * @param {ChatCompletionPayload} custom430 * @param {ChatCompletionPayload} custom
328 * @param {Object} options - Configuration options431 * @param {Object} options - Configuration options
329 * @param {string?} [options.presetName] - Name of the preset to use for generation settings432 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
330 * @param {boolean} extractData - Whether to extract structured data from response433 * @param {boolean} [extractData=true] - Whether to extract structured data from response
331 * @returns {Promise<ExtractedData | any>} Extracted data or the raw response434 * @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 * @throws {Error}436 * @throws {Error}
333 */437 */
334 static async processRequest(custom, options, extractData = true) {438 static async processRequest(custom, options, extractData = true, signal = null) {
335 const { presetName } = options;439 const { presetName } = options;
336 let requestData = { ...custom };440 let requestData = { ...custom };
337441
@@ -354,7 +458,7 @@ export class ChatCompletionService {
354458
355 const data = this.createRequestData(requestData);459 const data = this.createRequestData(requestData);
356460
357 return await this.sendRequest(data, extractData);461 return await this.sendRequest(data, extractData, signal);
358 }462 }
359463
360 /**464 /**
public/scripts/extensions/shared.js+10 -6
@@ -276,10 +276,12 @@ export async function getWebLlmContextSize() {
276}276}
277277
278/**278/**
279 * It uses the profiles to send a generate request to the API. Doesn't support streaming.279 * It uses the profiles to send a generate request to the API.
280 */280 */
281export class ConnectionManagerRequestService {281export class ConnectionManagerRequestService {
282 static defaultSendRequestParams = {282 static defaultSendRequestParams = {
283 stream: false,
284 signal: null,
283 extractData: true,285 extractData: true,
284 includePreset: true,286 includePreset: true,
285 includeInstruct: true,287 includeInstruct: true,
@@ -296,11 +298,11 @@ export class ConnectionManagerRequestService {
296 * @param {string} profileId298 * @param {string} profileId
297 * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt299 * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt
298 * @param {number} maxTokens300 * @param {number} maxTokens
299 * @param {{extractData?: boolean, includePreset?: boolean, includeInstruct?: boolean}} custom - default values are true301 * @param {{stream?: boolean, signal?: AbortSignal, extractData?: boolean, includePreset?: boolean, includeInstruct?: boolean}} custom - default values are true
300 * @returns {Promise<import('../custom-request.js').ExtractedData | any>} Extracted data or the raw response302 * @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
301 */303 */
302 static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) {304 static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) {
303 const { extractData, includePreset, includeInstruct } = { ...this.defaultSendRequestParams, ...custom };305 const { stream, signal, extractData, includePreset, includeInstruct } = { ...this.defaultSendRequestParams, ...custom };
304306
305 const context = SillyTavern.getContext();307 const context = SillyTavern.getContext();
306 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {308 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
@@ -319,6 +321,7 @@ export class ConnectionManagerRequestService {
319321
320 const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }];322 const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }];
321 return await context.ChatCompletionService.processRequest({323 return await context.ChatCompletionService.processRequest({
324 stream,
322 messages,325 messages,
323 max_tokens: maxTokens,326 max_tokens: maxTokens,
324 model: profile.model,327 model: profile.model,
@@ -326,7 +329,7 @@ export class ConnectionManagerRequestService {
326 custom_url: profile['api-url'],329 custom_url: profile['api-url'],
327 }, {330 }, {
328 presetName: includePreset ? profile.preset : undefined,331 presetName: includePreset ? profile.preset : undefined,
329 }, extractData);332 }, extractData, signal);
330 }333 }
331 case 'textgenerationwebui': {334 case 'textgenerationwebui': {
332 if (!selectedApiMap.type) {335 if (!selectedApiMap.type) {
@@ -334,6 +337,7 @@ export class ConnectionManagerRequestService {
334 }337 }
335338
336 return await context.TextCompletionService.processRequest({339 return await context.TextCompletionService.processRequest({
340 stream,
337 prompt,341 prompt,
338 max_tokens: maxTokens,342 max_tokens: maxTokens,
339 model: profile.model,343 model: profile.model,
@@ -342,7 +346,7 @@ export class ConnectionManagerRequestService {
342 }, {346 }, {
343 instructName: includeInstruct ? profile.instruct : undefined,347 instructName: includeInstruct ? profile.instruct : undefined,
344 presetName: includePreset ? profile.preset : undefined,348 presetName: includePreset ? profile.preset : undefined,
345 }, extractData);349 }, extractData, signal);
346 }350 }
347 default: {351 default: {
348 throw new Error(`Unknown API type ${selectedApiMap.selected}`);352 throw new Error(`Unknown API type ${selectedApiMap.selected}`);
public/scripts/group-chats.js+4 -3
@@ -882,7 +882,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
882 activatedMembers = activateListOrder(enabledMembers);882 activatedMembers = activateListOrder(enabledMembers);
883 }883 }
884 else if (activationStrategy === group_activation_strategy.POOLED) {884 else if (activationStrategy === group_activation_strategy.POOLED) {
885 activatedMembers = activatePooledOrder(enabledMembers, lastMessage);885 activatedMembers = activatePooledOrder(enabledMembers, lastMessage, isUserInput);
886 }886 }
887 else if (activationStrategy === group_activation_strategy.MANUAL && !isUserInput) {887 else if (activationStrategy === group_activation_strategy.MANUAL && !isUserInput) {
888 activatedMembers = shuffle(enabledMembers).slice(0, 1).map(x => characters.findIndex(y => y.avatar === x)).filter(x => x !== -1);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 * Activate group members based on the last message.1030 * Activate group members based on the last message.
1031 * @param {string[]} members List of member avatars1031 * @param {string[]} members List of member avatars
1032 * @param {Object} lastMessage Last message1032 * @param {Object} lastMessage Last message
1033 * @param {boolean} isUserInput Whether the user has input text
1033 * @returns {number[]} List of character ids1034 * @returns {number[]} List of character ids
1034 */1035 */
1035function activatePooledOrder(members, lastMessage) {1036function activatePooledOrder(members, lastMessage, isUserInput) {
1036 /** @type {string} */1037 /** @type {string} */
1037 let activatedMember = null;1038 let activatedMember = null;
1038 /** @type {string[]} */1039 /** @type {string[]} */
1039 const spokenSinceUser = [];1040 const spokenSinceUser = [];
10401041
1041 for (const message of chat.slice().reverse()) {1042 for (const message of chat.slice().reverse()) {
1042 if (message.is_user) {1043 if (message.is_user || isUserInput) {
1043 break;1044 break;
1044 }1045 }
10451046
public/scripts/openai.js+36 -21
@@ -1444,8 +1444,10 @@ export async function prepareOpenAIMessages({
1444 * Handles errors during streaming requests.1444 * Handles errors during streaming requests.
1445 * @param {Response} response1445 * @param {Response} response
1446 * @param {string} decoded - response text or decoded stream data1446 * @param {string} decoded - response text or decoded stream data
1447 * @param {object} [options]
1448 * @param {boolean?} [options.quiet=false] Suppress toast messages
1447 */1449 */
1448function tryParseStreamingError(response, decoded) {1450export function tryParseStreamingError(response, decoded, { quiet = false } = {}) {
1449 try {1451 try {
1450 const data = JSON.parse(decoded);1452 const data = JSON.parse(decoded);
14511453
@@ -1453,19 +1455,19 @@ function tryParseStreamingError(response, decoded) {
1453 return;1455 return;
1454 }1456 }
14551457
1456 checkQuotaError(data);1458 checkQuotaError(data, { quiet });
1457 checkModerationError(data);1459 checkModerationError(data, { quiet });
14581460
1459 // these do not throw correctly (equiv to Error("[object Object]"))1461 // these do not throw correctly (equiv to Error("[object Object]"))
1460 // if trying to fix "[object Object]" displayed to users, start here1462 // if trying to fix "[object Object]" displayed to users, start here
14611463
1462 if (data.error) {1464 if (data.error) {
1463 toastr.error(data.error.message || response.statusText, 'Chat Completion API');1465 !quiet && toastr.error(data.error.message || response.statusText, 'Chat Completion API');
1464 throw new Error(data);1466 throw new Error(data);
1465 }1467 }
14661468
1467 if (data.message) {1469 if (data.message) {
1468 toastr.error(data.message, 'Chat Completion API');1470 !quiet && toastr.error(data.message, 'Chat Completion API');
1469 throw new Error(data);1471 throw new Error(data);
1470 }1472 }
1471 }1473 }
@@ -1477,16 +1479,18 @@ function tryParseStreamingError(response, decoded) {
1477/**1479/**
1478 * Checks if the response contains a quota error and displays a popup if it does.1480 * Checks if the response contains a quota error and displays a popup if it does.
1479 * @param data1481 * @param data
1482 * @param {object} [options]
1483 * @param {boolean?} [options.quiet=false] Suppress toast messages
1480 * @returns {void}1484 * @returns {void}
1481 * @throws {object} - response JSON1485 * @throws {object} - response JSON
1482 */1486 */
1483function checkQuotaError(data) {1487function checkQuotaError(data, { quiet = false } = {}) {
1484 if (!data) {1488 if (!data) {
1485 return;1489 return;
1486 }1490 }
14871491
1488 if (data.quota_error) {1492 if (data.quota_error) {
1489 renderTemplateAsync('quotaError').then((html) => Popup.show.text('Quota Error', html));1493 !quiet && renderTemplateAsync('quotaError').then((html) => Popup.show.text('Quota Error', html));
14901494
1491 // this does not throw correctly (equiv to Error("[object Object]"))1495 // this does not throw correctly (equiv to Error("[object Object]"))
1492 // if trying to fix "[object Object]" displayed to users, start here1496 // if trying to fix "[object Object]" displayed to users, start here
@@ -1494,9 +1498,14 @@ function checkQuotaError(data) {
1494 }1498 }
1495}1499}
14961500
1497function checkModerationError(data) {1501/**
1502 * @param {any} data
1503 * @param {object} [options]
1504 * @param {boolean?} [options.quiet=false] Suppress toast messages
1505 */
1506function checkModerationError(data, { quiet = false } = {}) {
1498 const moderationError = data?.error?.message?.includes('requires moderation');1507 const moderationError = data?.error?.message?.includes('requires moderation');
1499 if (moderationError) {1508 if (moderationError && !quiet) {
1500 const moderationReason = `Reasons: ${data?.error?.metadata?.reasons?.join(', ') ?? '(N/A)'}`;1509 const moderationReason = `Reasons: ${data?.error?.metadata?.reasons?.join(', ') ?? '(N/A)'}`;
1501 const flaggedText = data?.error?.metadata?.flagged_input ?? '(N/A)';1510 const flaggedText = data?.error?.metadata?.flagged_input ?? '(N/A)';
1502 toastr.info(flaggedText, moderationReason, { timeOut: 10000 });1511 toastr.info(flaggedText, moderationReason, { timeOut: 10000 });
@@ -2255,37 +2264,43 @@ async function sendOpenAIRequest(type, messages, signal) {
2255 * Extracts the reply from the response data from a chat completions-like source2264 * Extracts the reply from the response data from a chat completions-like source
2256 * @param {object} data Response data from the chat completions-like source2265 * @param {object} data Response data from the chat completions-like source
2257 * @param {object} state Additional state to keep track of2266 * @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 * @returns {string} The reply extracted from the response data2270 * @returns {string} The reply extracted from the response data
2259 */2271 */
2260function getStreamingReply(data, state) {2272export 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 state.reasoning += data?.delta?.thinking || '';2278 state.reasoning += data?.delta?.thinking || '';
2264 }2279 }
2265 return data?.delta?.text || '';2280 return data?.delta?.text || '';
2266 } else if (oai_settings.chat_completion_source === chat_completion_sources.MAKERSUITE) {2281 } else if (chat_completion_source === chat_completion_sources.MAKERSUITE) {
2267 const inlineData = data?.candidates?.[0]?.content?.parts?.find(x => x.inlineData)?.inlineData;2282 const inlineData = data?.candidates?.[0]?.content?.parts?.find(x => x.inlineData)?.inlineData;
2268 if (inlineData) {2283 if (inlineData) {
2269 state.image = `data:${inlineData.mimeType};base64,${inlineData.data}`;2284 state.image = `data:${inlineData.mimeType};base64,${inlineData.data}`;
2270 }2285 }
2271 if (oai_settings.show_thoughts) {2286 if (show_thoughts) {
2272 state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || '');2287 state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || '');
2273 }2288 }
2274 return data?.candidates?.[0]?.content?.parts?.filter(x => !x.thought)?.map(x => x.text)?.[0] || '';2289 return data?.candidates?.[0]?.content?.parts?.filter(x => !x.thought)?.map(x => x.text)?.[0] || '';
2275 } else if (oai_settings.chat_completion_source === chat_completion_sources.COHERE) {2290 } else if (chat_completion_source === chat_completion_sources.COHERE) {
2276 return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || '';2291 return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || '';
2277 } else if (oai_settings.chat_completion_source === chat_completion_sources.DEEPSEEK) {2292 } else if (chat_completion_source === chat_completion_sources.DEEPSEEK) {
2278 if (oai_settings.show_thoughts) {2293 if (show_thoughts) {
2279 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content || '');2294 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content || '');
2280 }2295 }
2281 return data.choices?.[0]?.delta?.content || '';2296 return data.choices?.[0]?.delta?.content || '';
2282 } else if (oai_settings.chat_completion_source === chat_completion_sources.OPENROUTER) {2297 } else if (chat_completion_source === chat_completion_sources.OPENROUTER) {
2283 if (oai_settings.show_thoughts) {2298 if (show_thoughts) {
2284 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');2299 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
2285 }2300 }
2286 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';2301 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
2287 } else if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) {2302 } else if (chat_completion_source === chat_completion_sources.CUSTOM) {
2288 if (oai_settings.show_thoughts) {2303 if (show_thoughts) {
2289 state.reasoning +=2304 state.reasoning +=
2290 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??2305 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
2291 data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ??2306 data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ??
public/scripts/slash-commands.js+66 -15
@@ -69,7 +69,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js';
69import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';69import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
70import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';70import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
71import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';71import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
72import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';72import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
73import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';73import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
74import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';74import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
75import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';75import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
@@ -77,6 +77,7 @@ import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHel
77import { accountStorage } from './util/AccountStorage.js';77import { accountStorage } from './util/AccountStorage.js';
78import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';78import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
79import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';79import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
80import { t } from './i18n.js';
80export {81export {
81 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,82 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
82};83};
@@ -1556,16 +1557,28 @@ export function initDefaultSlashCommands() {
1556 SlashCommandParser.addCommandObject(SlashCommand.fromProps({1557 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1557 name: 'buttons',1558 name: 'buttons',
1558 callback: buttonsCallback,1559 callback: buttonsCallback,
1559 returns: 'clicked button label',1560 returns: 'clicked button label (or array of labels if multiple is enabled)',
1560 namedArgumentList: [1561 namedArgumentList: [
1561 new SlashCommandNamedArgument(1562 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 unnamedArgumentList: [1576 unnamedArgumentList: [
1566 new SlashCommandArgument(1577 SlashCommandArgument.fromProps({
1567 'text', [ARGUMENT_TYPE.STRING], true,1578 description: 'text',
1568 ),1579 typeList: [ARGUMENT_TYPE.STRING],
1580 isRequired: true,
1581 }),
1569 ],1582 ],
1570 helpString: `1583 helpString: `
1571 <div>1584 <div>
@@ -2377,6 +2390,18 @@ async function trimTokensCallback(arg, value) {
2377 }2390 }
2378}2391}
23792392
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 */
2380async function buttonsCallback(args, text) {2405async function buttonsCallback(args, text) {
2381 try {2406 try {
2382 /** @type {string[]} */2407 /** @type {string[]} */
@@ -2387,6 +2412,10 @@ async function buttonsCallback(args, text) {
2387 return '';2412 return '';
2388 }2413 }
23892414
2415 /** @type {Set<number>} */
2416 const multipleToggledState = new Set();
2417 const multiple = isTrueBoolean(args?.multiple);
2418
2390 // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel2419 // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel
2391 const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));2420 const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));
23922421
@@ -2404,11 +2433,24 @@ async function buttonsCallback(args, text) {
24042433
2405 for (const [result, button] of resultToButtonMap) {2434 for (const [result, button] of resultToButtonMap) {
2406 const buttonElement = document.createElement('div');2435 const buttonElement = document.createElement('div');
2407 buttonElement.classList.add('menu_button', 'result-control', 'wide100p');2436 buttonElement.classList.add('menu_button', '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 buttonElement.innerText = button;2454 buttonElement.innerText = button;
2413 buttonContainer.appendChild(buttonElement);2455 buttonContainer.appendChild(buttonElement);
2414 }2456 }
@@ -2424,10 +2466,19 @@ async function buttonsCallback(args, text) {
2424 popupContainer.style.flexDirection = 'column';2466 popupContainer.style.flexDirection = 'column';
2425 popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup2467 popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup
24262468
2427 popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'Cancel', allowVerticalScrolling: true });2469 popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: multiple ? t`Ok` : t`Cancel`, allowVerticalScrolling: true });
2428 popup.show()2470 popup.show()
2429 .then((result => resolve(typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : '')))2471 .then((result => resolve(getResult(result))))
2430 .catch(() => resolve(''));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 } catch {2483 } catch {
2433 return '';2484 return '';
public/style.css+30 -1
@@ -2911,6 +2911,35 @@ select option:not(:checked) {
2911 pointer-events: none;2911 pointer-events: none;
2912}2912}
29132913
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.fav_on {2943.fav_on {
2915 color: var(--golden) !important;2944 color: var(--golden) !important;
2916}2945}
@@ -2921,7 +2950,7 @@ select option:not(:checked) {
2921}2950}
29222951
2923.menu_button.togglable:not(.toggleEnabled) {2952.menu_button.togglable:not(.toggleEnabled) {
2924 color: red;2953 color: var(--fullred);
2925}2954}
29262955
2927.displayBlock {2956.displayBlock {