Merge branch 'staging' into disk-cache

834c9751f8c71bcb7503263b6b708d7f0061d720

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

8 files changed, +276 -64Showing whitespace changes
.github/workflows/pr-auto-manager.yml+7 -0
@@ -1,6 +1,7 @@
11name: 🔀 Pull Request Manager
22
33on:
4+ workflow_dispatch: # Allow to manually call this workflow
45 pull_request_target:
56 types: [opened, synchronize, reopened, edited, labeled, unlabeled, closed]
67 pull_request_review_comment:
@@ -17,6 +18,12 @@ jobs:
1718 # Only needs to run when code is changed
1819 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+
2027 steps:
2128 - name: Checkout Repository
2229 # Checkout
public/script.js+3 -2
@@ -2069,8 +2069,9 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
20692069 }
20702070
20712071 // 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);
20742075 }
20752076
20762077 if (!isSystem) {
public/scripts/custom-request.js+122 -18
@@ -3,10 +3,13 @@ import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../sc
33import { getTextGenServer } from './textgen-settings.js';
44import { extractReasoningFromData } from './reasoning.js';
55import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types } from './instruct-mode.js';
6+import { getStreamingReply, tryParseStreamingError } from './openai.js';
7+import EventSourceStream from './sse-stream.js';
68
79// #region Type Definitions
810/**
911 * @typedef {Object} TextCompletionRequestBase
12+ * @property {boolean?} [stream=false] - Whether to stream the response
1013 * @property {number} max_tokens - Maximum number of tokens to generate
1114 * @property {string} [model] - Optional model name
1215 * @property {string} api_type - Type of API to use
@@ -17,6 +20,7 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
1720
1821/**
1922 * @typedef {Object} TextCompletionPayloadBase
23+ * @property {boolean?} [stream=false] - Whether to stream the response
2024 * @property {string} prompt - The text prompt for completion
2125 * @property {number} max_tokens - Maximum number of tokens to generate
2226 * @property {number} max_new_tokens - Alias for max_tokens
@@ -36,6 +40,7 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
3640
3741/**
3842 * @typedef {Object} ChatCompletionPayloadBase
43+ * @property {boolean?} [stream=false] - Whether to stream the response
3944 * @property {ChatCompletionMessage[]} messages - Array of chat messages
4045 * @property {string} [model] - Optional model name to use for completion
4146 * @property {string} chat_completion_source - Source provider for chat completion
@@ -52,10 +57,19 @@ import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types
5257 * @property {string} reasoning - Extracted reasoning.
5358 */
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+
5569// #endregion
5670
5771/**
5872 * Creates & sends a text completion request. Streaming is not supported.
5973 */
6074export class TextCompletionService {
6175 static TYPE = 'textgenerationwebui';
@@ -64,9 +78,10 @@ export class TextCompletionService {
6478 * @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom
6579 * @returns {TextCompletionPayload}
6680 */
6781 static createRequestData({ stream = false, prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {
6882 const payload = {
6983 ...props,
84+ stream,
7085 prompt,
7186 max_tokens,
7287 max_new_tokens: max_tokens,
@@ -75,7 +90,6 @@ export class TextCompletionService {
7590 api_server: api_server ?? getTextGenServer(api_type),
7691 temperature,
7792 min_p,
78- stream: false,
7993 };
8094
8195 // Remove undefined values to avoid API errors
@@ -92,16 +106,18 @@ export class TextCompletionService {
92106 * Sends a text completion request to the specified server
93107 * @param {TextCompletionPayload} data Request data
94108 * @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
96111 * @throws {Error}
97112 */
98113 static async sendRequest(data, extractData = true, signal = null) {
114+ if (!data.stream) {
99115 const response = await fetch(getGenerateUrl(this.TYPE), {
100116 method: 'POST',
101117 headers: getRequestHeaders(),
102118 cache: 'no-cache',
103119 body: JSON.stringify(data),
104120 signal: signal ?? new AbortController().signal,
105121 });
106122
107123 const json = await response.json();
@@ -123,6 +139,51 @@ export class TextCompletionService {
123139 };
124140 }
125141
142+ const response = await fetch('/api/backends/text-completions/generate', {
143+ method: 'POST',
144+ headers: getRequestHeaders(),
145+ cache: 'no-cache',
146+ body: JSON.stringify(data),
147+ signal: signal ?? new AbortController().signal,
148+ });
149+
150+ if (!response.ok) {
151+ const text = await response.text();
152+ tryParseStreamingError(response, text, { quiet: true });
153+
154+ throw new Error(`Got response status ${response.status}`);
155+ }
156+
157+ const eventStream = new EventSourceStream();
158+ response.body.pipeThrough(eventStream);
159+ const reader = eventStream.readable.getReader();
160+ return async function* streamData() {
161+ let text = '';
162+ const swipes = [];
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+ }
184+ };
185+ }
186+
126187 /**
127188 * Process and send a text completion request with optional preset & instruct
128189 * @param {Record<string, any> & TextCompletionRequestBase & {prompt: (ChatCompletionMessage & {ignoreInstruct?: boolean})[] |string}} custom
@@ -130,13 +191,15 @@ export class TextCompletionService {
130191 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
131192 * @param {string?} [options.instructName] - Name of instruct preset for message formatting
132193 * @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
134196 * @throws {Error}
135197 */
136198 static async processRequest(
137199 custom,
138200 options = {},
139201 extractData = true,
202+ signal = null,
140203 ) {
141204 const { presetName, instructName } = options;
142205 let requestData = { ...custom };
@@ -220,7 +283,7 @@ export class TextCompletionService {
220283 // @ts-ignore
221284 const data = this.createRequestData(requestData);
222285
223286 return await this.sendRequest(data, extractData, signal);
224287 }
225288
226289 /**
@@ -256,7 +319,7 @@ export class TextCompletionService {
256319}
257320
258321/**
259322 * Creates & sends a chat completion request. Streaming is not supported.
260323 */
261324export class ChatCompletionService {
262325 static TYPE = 'openai';
@@ -265,16 +328,16 @@ export class ChatCompletionService {
265328 * @param {ChatCompletionPayload} custom
266329 * @returns {ChatCompletionPayload}
267330 */
268331 static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, ...props }) {
269332 const payload = {
270333 ...props,
334+ stream,
271335 messages,
272336 model,
273337 chat_completion_source,
274338 max_tokens,
275339 temperature,
276340 custom_url,
277- stream: false,
278341 };
279342
280343 // Remove undefined values to avoid API errors
@@ -291,18 +354,20 @@ export class ChatCompletionService {
291354 * Sends a chat completion request
292355 * @param {ChatCompletionPayload} data Request data
293356 * @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
295359 * @throws {Error}
296360 */
297361 static async sendRequest(data, extractData = true, signal = null) {
298362 const response = await fetch('/api/backends/chat-completions/generate', {
299363 method: 'POST',
300364 headers: getRequestHeaders(),
301365 cache: 'no-cache',
302366 body: JSON.stringify(data),
303367 signal: signal ?? new AbortController().signal,
304368 });
305369
370+ if (!data.stream) {
306371 const json = await response.json();
307372 if (!response.ok || json.error) {
308373 throw json;
@@ -322,16 +387,55 @@ export class ChatCompletionService {
322387 };
323388 }
324389
390+ if (!response.ok) {
391+ const text = await response.text();
392+ tryParseStreamingError(response, text, { quiet: true });
393+
394+ throw new Error(`Got response status ${response.status}`);
395+ }
396+
397+ const eventStream = new EventSourceStream();
398+ response.body.pipeThrough(eventStream);
399+ const reader = eventStream.readable.getReader();
400+ return async function* streamData() {
401+ let text = '';
402+ const swipes = [];
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+ }
425+ };
426+ }
427+
325428 /**
326429 * Process and send a chat completion request with optional preset
327430 * @param {ChatCompletionPayload} custom
328431 * @param {Object} options - Configuration options
329432 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
330433 * @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
332436 * @throws {Error}
333437 */
334438 static async processRequest(custom, options, extractData = true, signal = null) {
335439 const { presetName } = options;
336440 let requestData = { ...custom };
337441
@@ -354,7 +458,7 @@ export class ChatCompletionService {
354458
355459 const data = this.createRequestData(requestData);
356460
357461 return await this.sendRequest(data, extractData, signal);
358462 }
359463
360464 /**
public/scripts/extensions/shared.js+10 -6
@@ -276,10 +276,12 @@ export async function getWebLlmContextSize() {
276276}
277277
278278/**
279279 * It uses the profiles to send a generate request to the API. Doesn't support streaming.
280280 */
281281export class ConnectionManagerRequestService {
282282 static defaultSendRequestParams = {
283+ stream: false,
284+ signal: null,
283285 extractData: true,
284286 includePreset: true,
285287 includeInstruct: true,
@@ -296,11 +298,11 @@ export class ConnectionManagerRequestService {
296298 * @param {string} profileId
297299 * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt
298300 * @param {number} maxTokens
299301 * @param {{stream?: boolean, signal?: AbortSignal, extractData?: boolean, includePreset?: boolean, includeInstruct?: boolean}} custom - default values are true
300302 * @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
301303 */
302304 static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) {
303305 const { stream, signal, extractData, includePreset, includeInstruct } = { ...this.defaultSendRequestParams, ...custom };
304306
305307 const context = SillyTavern.getContext();
306308 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
@@ -319,6 +321,7 @@ export class ConnectionManagerRequestService {
319321
320322 const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }];
321323 return await context.ChatCompletionService.processRequest({
324+ stream,
322325 messages,
323326 max_tokens: maxTokens,
324327 model: profile.model,
@@ -326,7 +329,7 @@ export class ConnectionManagerRequestService {
326329 custom_url: profile['api-url'],
327330 }, {
328331 presetName: includePreset ? profile.preset : undefined,
329332 }, extractData, signal);
330333 }
331334 case 'textgenerationwebui': {
332335 if (!selectedApiMap.type) {
@@ -334,6 +337,7 @@ export class ConnectionManagerRequestService {
334337 }
335338
336339 return await context.TextCompletionService.processRequest({
340+ stream,
337341 prompt,
338342 max_tokens: maxTokens,
339343 model: profile.model,
@@ -342,7 +346,7 @@ export class ConnectionManagerRequestService {
342346 }, {
343347 instructName: includeInstruct ? profile.instruct : undefined,
344348 presetName: includePreset ? profile.preset : undefined,
345349 }, extractData, signal);
346350 }
347351 default: {
348352 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 = {}) {
882882 activatedMembers = activateListOrder(enabledMembers);
883883 }
884884 else if (activationStrategy === group_activation_strategy.POOLED) {
885885 activatedMembers = activatePooledOrder(enabledMembers, lastMessage, isUserInput);
886886 }
887887 else if (activationStrategy === group_activation_strategy.MANUAL && !isUserInput) {
888888 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) {
10301030 * Activate group members based on the last message.
10311031 * @param {string[]} members List of member avatars
10321032 * @param {Object} lastMessage Last message
1033+ * @param {boolean} isUserInput Whether the user has input text
10331034 * @returns {number[]} List of character ids
10341035 */
10351036function activatePooledOrder(members, lastMessage, isUserInput) {
10361037 /** @type {string} */
10371038 let activatedMember = null;
10381039 /** @type {string[]} */
10391040 const spokenSinceUser = [];
10401041
10411042 for (const message of chat.slice().reverse()) {
10421043 if (message.is_user || isUserInput) {
10431044 break;
10441045 }
10451046
public/scripts/openai.js+36 -21
@@ -1444,8 +1444,10 @@ export async function prepareOpenAIMessages({
14441444 * Handles errors during streaming requests.
14451445 * @param {Response} response
14461446 * @param {string} decoded - response text or decoded stream data
1447+ * @param {object} [options]
1448+ * @param {boolean?} [options.quiet=false] Suppress toast messages
14471449 */
14481450export function tryParseStreamingError(response, decoded, { quiet = false } = {}) {
14491451 try {
14501452 const data = JSON.parse(decoded);
14511453
@@ -1453,19 +1455,19 @@ function tryParseStreamingError(response, decoded) {
14531455 return;
14541456 }
14551457
14561458 checkQuotaError(data, { quiet });
14571459 checkModerationError(data, { quiet });
14581460
14591461 // these do not throw correctly (equiv to Error("[object Object]"))
14601462 // if trying to fix "[object Object]" displayed to users, start here
14611463
14621464 if (data.error) {
14631465 !quiet && toastr.error(data.error.message || response.statusText, 'Chat Completion API');
14641466 throw new Error(data);
14651467 }
14661468
14671469 if (data.message) {
14681470 !quiet && toastr.error(data.message, 'Chat Completion API');
14691471 throw new Error(data);
14701472 }
14711473 }
@@ -1477,16 +1479,18 @@ function tryParseStreamingError(response, decoded) {
14771479/**
14781480 * Checks if the response contains a quota error and displays a popup if it does.
14791481 * @param data
1482+ * @param {object} [options]
1483+ * @param {boolean?} [options.quiet=false] Suppress toast messages
14801484 * @returns {void}
14811485 * @throws {object} - response JSON
14821486 */
14831487function checkQuotaError(data, { quiet = false } = {}) {
14841488 if (!data) {
14851489 return;
14861490 }
14871491
14881492 if (data.quota_error) {
14891493 !quiet && renderTemplateAsync('quotaError').then((html) => Popup.show.text('Quota Error', html));
14901494
14911495 // this does not throw correctly (equiv to Error("[object Object]"))
14921496 // if trying to fix "[object Object]" displayed to users, start here
@@ -1494,9 +1498,14 @@ function checkQuotaError(data) {
14941498 }
14951499}
14961500
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 } = {}) {
14981507 const moderationError = data?.error?.message?.includes('requires moderation');
14991508 if (moderationError && !quiet) {
15001509 const moderationReason = `Reasons: ${data?.error?.metadata?.reasons?.join(', ') ?? '(N/A)'}`;
15011510 const flaggedText = data?.error?.metadata?.flagged_input ?? '(N/A)';
15021511 toastr.info(flaggedText, moderationReason, { timeOut: 10000 });
@@ -2255,37 +2264,43 @@ async function sendOpenAIRequest(type, messages, signal) {
22552264 * Extracts the reply from the response data from a chat completions-like source
22562265 * @param {object} data Response data from the chat completions-like source
22572266 * @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
22582270 * @returns {string} The reply extracted from the response data
22592271 */
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) {
22632278 state.reasoning += data?.delta?.thinking || '';
22642279 }
22652280 return data?.delta?.text || '';
22662281 } else if (oai_settings.chat_completion_source === chat_completion_sources.MAKERSUITE) {
22672282 const inlineData = data?.candidates?.[0]?.content?.parts?.find(x => x.inlineData)?.inlineData;
22682283 if (inlineData) {
22692284 state.image = `data:${inlineData.mimeType};base64,${inlineData.data}`;
22702285 }
22712286 if (oai_settings.show_thoughts) {
22722287 state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || '');
22732288 }
22742289 return data?.candidates?.[0]?.content?.parts?.filter(x => !x.thought)?.map(x => x.text)?.[0] || '';
22752290 } else if (oai_settings.chat_completion_source === chat_completion_sources.COHERE) {
22762291 return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || '';
22772292 } else if (oai_settings.chat_completion_source === chat_completion_sources.DEEPSEEK) {
22782293 if (oai_settings.show_thoughts) {
22792294 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content || '');
22802295 }
22812296 return data.choices?.[0]?.delta?.content || '';
22822297 } else if (oai_settings.chat_completion_source === chat_completion_sources.OPENROUTER) {
22832298 if (oai_settings.show_thoughts) {
22842299 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
22852300 }
22862301 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
22872302 } else if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) {
22882303 if (oai_settings.show_thoughts) {
22892304 state.reasoning +=
22902305 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
22912306 data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ??
public/scripts/slash-commands.js+64 -13
@@ -69,7 +69,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js';
6969import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
7070import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
7171import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
7272import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
7373import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
7474import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
7575import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
@@ -77,6 +77,7 @@ import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHel
7777import { accountStorage } from './util/AccountStorage.js';
7878import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
7979import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
80+import { t } from './i18n.js';
8081export {
8182 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
8283};
@@ -1556,16 +1557,28 @@ export function initDefaultSlashCommands() {
15561557 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
15571558 name: 'buttons',
15581559 callback: buttonsCallback,
15591560 returns: 'clicked button label (or array of labels if multiple is enabled)',
15601561 namedArgumentList: [
15611562 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+ }),
15641575 ],
15651576 unnamedArgumentList: [
15661577 new SlashCommandArgument.fromProps({
1567- 'text', [ARGUMENT_TYPE.STRING], true,
1578+ description: 'text',
1568- ),
1579+ typeList: [ARGUMENT_TYPE.STRING],
1580+ isRequired: true,
1581+ }),
15691582 ],
15701583 helpString: `
15711584 <div>
@@ -2377,6 +2390,18 @@ async function trimTokensCallback(arg, value) {
23772390 }
23782391}
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+ */
23802405async function buttonsCallback(args, text) {
23812406 try {
23822407 /** @type {string[]} */
@@ -2387,6 +2412,10 @@ async function buttonsCallback(args, text) {
23872412 return '';
23882413 }
23892414
2415+ /** @type {Set<number>} */
2416+ const multipleToggledState = new Set();
2417+ const multiple = isTrueBoolean(args?.multiple);
2418+
23902419 // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel
23912420 const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));
23922421
@@ -2404,11 +2433,24 @@ async function buttonsCallback(args, text) {
24042433
24052434 for (const [result, button] of resultToButtonMap) {
24062435 const buttonElement = document.createElement('div');
24072436 buttonElement.classList.add('menu_button', 'result-control', 'wide100p');
2408- buttonElement.dataset.result = String(result);
2437+
2438+ if (multiple) {
2439+ buttonElement.classList.add('toggleable');
2440+ buttonElement.dataset.toggleValue = String(result);
24092441 buttonElement.addEventListener('click', async () => {
2410- await popup.complete(result);
2442+ buttonElement.classList.toggle('toggled');
2443+ if (buttonElement.classList.contains('toggled')) {
2444+ multipleToggledState.add(result);
2445+ } else {
2446+ multipleToggledState.delete(result);
2447+ }
24112448 });
2449+ } else {
2450+ buttonElement.classList.add('result-control');
2451+ buttonElement.dataset.result = String(result);
2452+ }
2453+
24122454 buttonElement.innerText = button;
24132455 buttonContainer.appendChild(buttonElement);
24142456 }
@@ -2424,10 +2466,19 @@ async function buttonsCallback(args, text) {
24242466 popupContainer.style.flexDirection = 'column';
24252467 popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup
24262468
24272469 popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'multiple ? t`Ok` : t`Cancel'`, allowVerticalScrolling: true });
24282470 popup.show()
24292471 .then((result => resolve(typeof result === 'number' ? resultToButtonMap.getgetResult(result) ?? '' : '')))
24302472 .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+ }
24312482 });
24322483 } catch {
24332484 return '';
public/style.css+30 -1
@@ -2911,6 +2911,35 @@ select option:not(:checked) {
29112911 pointer-events: none;
29122912}
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+
29142943.fav_on {
29152944 color: var(--golden) !important;
29162945}
@@ -2921,7 +2950,7 @@ select option:not(:checked) {
29212950}
29222951
29232952.menu_button.togglable:not(.toggleEnabled) {
29242953 color: redvar(--fullred);
29252954}
29262955
29272956.displayBlock {