Blame Raw
Cohee · e3f41666 · · 2490 lines (86.7 KB)
2 contributors
1import { Fuse } from '../lib.js';
2
3import {
4 shuffle,
5 onlyUnique,
6 debounce,
7 delay,
8 isDataURL,
9 createThumbnail,
10 extractAllWords,
11 saveBase64AsFile,
12 PAGINATION_TEMPLATE,
13 getBase64Async,
14 resetScrollHeight,
15 initScrollHeight,
16 localizePagination,
17 renderPaginationDropdown,
18 paginationDropdownChangeHandler,
19 waitUntilCondition,
20 uuidv4,
21} from './utils.js';
22import { RA_CountCharTokens, humanizedDateTime, dragElement, favsToHotswap, getMessageTimeStamp } from './RossAscends-mods.js';
23import { power_user, loadMovingUIState, sortEntitiesList } from './power-user.js';
24import { debounce_timeout } from './constants.js';
25
26import {
27 chat,
28 sendSystemMessage,
29 printMessages,
30 substituteParams,
31 characters,
32 default_avatar,
33 addOneMessage,
34 clearChat,
35 Generate,
36 select_rm_info,
37 setCharacterId,
38 setCharacterName,
39 setEditedMessageId,
40 is_send_press,
41 resetChatState,
42 setSendButtonState,
43 getCharacters,
44 system_message_types,
45 online_status,
46 talkativeness_default,
47 selectRightMenuWithAnimation,
48 deleteLastMessage,
49 showSwipeButtons,
50 hideSwipeButtons,
51 chat_metadata,
52 updateChatMetadata,
53 getThumbnailUrl,
54 getRequestHeaders,
55 setMenuType,
56 menu_type,
57 select_selected_character,
58 cancelTtsPlay,
59 displayPastChats,
60 sendMessageAsUser,
61 getBiasStrings,
62 saveChatConditional,
63 deactivateSendButtons,
64 activateSendButtons,
65 eventSource,
66 event_types,
67 getCurrentChatId,
68 setCharacterSettingsOverrides,
69 system_avatar,
70 isChatSaving,
71 setExternalAbortController,
72 baseChatReplace,
73 createLazyFields,
74 depth_prompt_depth_default,
75 loadItemizedPrompts,
76 animation_duration,
77 depth_prompt_role_default,
78 shouldAutoContinue,
79 unshallowCharacter,
80 chatElement,
81 ensureMessageMediaIsArray,
82} from '../script.js';
83import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js';
84import { FILTER_TYPES, FilterHelper } from './filters.js';
85import { isExternalMediaAllowed } from './chats.js';
86import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
87import { t } from './i18n.js';
88import { accountStorage } from './util/AccountStorage.js';
89import { compressRequest } from './request-compression.js';
90
91export {
92 selected_group,
93 openGroupId,
94 is_group_automode_enabled,
95 hideMutedSprites,
96 is_group_generating,
97 group_generation_id,
98 groups,
99 saveGroupChat,
100 generateGroupWrapper,
101 deleteGroup,
102 getGroupAvatar,
103 getGroups,
104 regenerateGroup,
105 resetSelectedGroup,
106 select_group_chats,
107 getGroupChatNames,
108};
109
110let is_group_generating = false; // Group generation flag
111let is_group_automode_enabled = false;
112let hideMutedSprites = false;
113/** @type {Group[]} */
114let groups = [];
115/** @type {string|null} */
116let selected_group = null;
117let group_generation_id = null;
118let fav_grp_checked = false;
119let openGroupId = null;
120let newGroupMembers = [];
121
122export const group_activation_strategy = {
123 NATURAL: 0,
124 LIST: 1,
125 MANUAL: 2,
126 POOLED: 3,
127};
128
129export const group_generation_mode = {
130 SWAP: 0,
131 APPEND: 1,
132 APPEND_DISABLED: 2,
133};
134
135export const DEFAULT_AUTO_MODE_DELAY = 5;
136
137export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick));
138export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick));
139let autoModeWorker = null;
140const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed);
141/** @type {Map<string, number>} */
142let groupChatQueueOrder = new Map();
143
144function setAutoModeWorker() {
145 clearInterval(autoModeWorker);
146 const autoModeDelay = groups.find(x => x.id === selected_group)?.auto_mode_delay ?? DEFAULT_AUTO_MODE_DELAY;
147 autoModeWorker = setInterval(groupChatAutoModeWorker, autoModeDelay * 1000);
148}
149
150/**
151 * Saves a group to the server.
152 * @param {Group} group Group object to save
153 * @param {boolean} reload Whether to reload characters after saving
154 */
155async function _save(group, reload = true) {
156 await fetch('/api/groups/edit', {
157 method: 'POST',
158 headers: getRequestHeaders(),
159 body: JSON.stringify(group),
160 });
161 if (reload) {
162 await getCharacters();
163 }
164}
165
166// Group chats
167async function regenerateGroup() {
168 let generationId = getLastMessageGenerationId();
169
170 while (chat.length > 0) {
171 const lastMes = chat[chat.length - 1];
172 const this_generationId = lastMes.extra?.gen_id;
173
174 // for new generations after the update
175 if ((generationId && this_generationId) && generationId !== this_generationId) {
176 break;
177 } else if (lastMes.is_user || lastMes.is_system) {
178 // legacy for generations before the update
179 break;
180 }
181
182 await deleteLastMessage();
183 }
184
185 const abortController = new AbortController();
186 setExternalAbortController(abortController);
187 return generateGroupWrapper(false, 'normal', { signal: abortController.signal });
188}
189
190/**
191 * Loads group chat messages from the server.
192 * @param {string} chatId Chat ID
193 * @returns {Promise<ChatFile>} Array of chat messages
194 */
195async function loadGroupChat(chatId) {
196 const response = await fetch('/api/chats/group/get', {
197 method: 'POST',
198 headers: getRequestHeaders(),
199 body: JSON.stringify({ id: chatId }),
200 });
201
202 if (response.ok) {
203 const data = await response.json();
204 if (!Array.isArray(data)) {
205 return [];
206 }
207 return data;
208 }
209
210 return [];
211}
212
213/**
214 * Validates a group by checking if all members exist and removing duplicates.
215 * @param {Group} group Group to validate
216 * @returns {Promise<void>}
217 */
218async function validateGroup(group) {
219 if (!group) return;
220
221 // Validate that all members exist as characters
222 let dirty = false;
223 group.members = group.members.filter(member => {
224 const character = characters.find(x => x.avatar === member || x.name === member);
225 if (!character) {
226 const msg = t`Warning: Listed member ${member} does not exist as a character. It will be removed from the group.`;
227 toastr.warning(msg, t`Group Validation`);
228 console.warn(msg);
229 dirty = true;
230 }
231 return character;
232 });
233
234 // Remove duplicate chat ids
235 if (Array.isArray(group.chats)) {
236 const lengthBefore = group.chats.length;
237 group.chats = group.chats.filter(onlyUnique);
238 const lengthAfter = group.chats.length;
239 if (lengthBefore !== lengthAfter) {
240 dirty = true;
241 }
242 }
243
244 if (dirty) {
245 await editGroup(group.id, true, false);
246 }
247}
248
249/**
250 * Loads the chat messages for a specific group.
251 * @param {string} groupId - The ID of the group to load chat messages for.
252 * @param {boolean} reload - Whether to reload the group chat after loading.
253 * @returns {Promise<void>} A promise that resolves when the chat messages have been loaded.
254 */
255export async function getGroupChat(groupId, reload = false) {
256 const group = groups.find((x) => x.id === groupId);
257 if (!group) {
258 console.warn('Group not found', groupId);
259 return;
260 }
261
262 // Run validation before any loading
263 await validateGroup(group);
264 await unshallowGroupMembers(groupId);
265
266 const chat_id = group.chat_id;
267 const data = await loadGroupChat(chat_id);
268 const metadata = data?.[0]?.chat_metadata ?? {};
269 const freshChat = !metadata.tainted && (!Array.isArray(data) || !data.length);
270
271 // Remove chat file header if present
272 if (Array.isArray(data) && data.length && Object.hasOwn(data[0], 'chat_metadata')) {
273 data.shift();
274 }
275
276 // Add integrity slug if missing
277 if (!metadata.integrity) {
278 metadata.integrity = uuidv4();
279 }
280
281 await loadItemizedPrompts(getCurrentChatId());
282
283 if (group && Array.isArray(group.members) && freshChat) {
284 chat.splice(0, chat.length);
285 chatElement.find('.mes').remove();
286 for (let member of group.members) {
287 const character = characters.find(x => x.avatar === member || x.name === member);
288 if (!character) {
289 continue;
290 }
291
292 const mes = await getFirstCharacterMessage(character);
293
294 // No first message
295 if (!(mes?.mes)) {
296 continue;
297 }
298
299 chat.push(mes);
300 await eventSource.emit(event_types.MESSAGE_RECEIVED, (chat.length - 1), 'first_message');
301 addOneMessage(mes);
302 await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, (chat.length - 1), 'first_message');
303 }
304 await saveGroupChat(groupId, false);
305 } else if (Array.isArray(data) && data.length) {
306 chat.splice(0, chat.length, ...data);
307 chat.forEach(ensureMessageMediaIsArray);
308 chatElement.find('.mes').remove();
309 await printMessages();
310 }
311
312 updateChatMetadata(metadata, true);
313
314 if (reload) {
315 select_group_chats(groupId, true);
316 }
317
318 await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
319 if (freshChat) await eventSource.emit(event_types.GROUP_CHAT_CREATED);
320}
321
322/**
323 * Retrieves the members of a group
324 *
325 * @param {string} [groupId=selected_group] - The ID of the group to retrieve members from. Defaults to the currently selected group.
326 * @returns {Character[]} An array of character objects representing the members of the group. If the group is not found, an empty array is returned.
327 */
328export function getGroupMembers(groupId = selected_group) {
329 const group = groups.find((x) => x.id === groupId);
330 return group?.members.map(member => characters.find(x => x.avatar === member)) ?? [];
331}
332
333/**
334 * Retrieves the member names of a group. If the group is not selected, an empty array is returned.
335 * @returns {string[]} An array of character names representing the members of the group.
336 */
337export function getGroupNames() {
338 if (!selected_group) {
339 return [];
340 }
341 const groupMembers = groups.find(x => x.id == selected_group)?.members;
342 return Array.isArray(groupMembers)
343 ? groupMembers.map(x => characters.find(y => y.avatar === x)?.name).filter(x => x)
344 : [];
345}
346
347/**
348 * Finds the character ID for a group member.
349 * @param {number|string} arg 0-based member index or character name
350 * @param {Boolean} full Whether to return a key-value object containing extra data
351 * @returns {number|Object} 0-based character ID or key-value object if full is true
352 */
353export function findGroupMemberId(arg, full = false) {
354 arg = arg?.toString()?.trim();
355
356 if (!arg) {
357 console.warn('WARN: No argument provided for findGroupMemberId');
358 return;
359 }
360
361 const group = groups.find(x => x.id == selected_group);
362
363 if (!group || !Array.isArray(group.members)) {
364 console.warn('WARN: No group found for selected group ID');
365 return;
366 }
367
368 const index = parseInt(arg);
369 const searchByString = isNaN(index);
370
371 if (searchByString) {
372 const memberNames = group.members.map(x => ({
373 avatar: x,
374 name: characters.find(y => y.avatar === x)?.name,
375 index: characters.findIndex(y => y.avatar === x),
376 }));
377 const fuse = new Fuse(memberNames, { keys: ['avatar', 'name'] });
378 const result = fuse.search(arg);
379
380 if (!result.length) {
381 console.warn(`WARN: No group member found using string ${arg}`);
382 return;
383 }
384
385 const chid = result[0].item.index;
386
387 if (chid === -1) {
388 console.warn(`WARN: No character found for group member ${arg}`);
389 return;
390 }
391
392 console.log(`Targeting group member ${chid} (${arg}) from search result`, result[0]);
393
394 return !full ? chid : { ...{ id: chid }, ...result[0].item };
395 } else {
396 const memberAvatar = group.members[index];
397
398 if (memberAvatar === undefined) {
399 console.warn(`WARN: No group member found at index ${index}`);
400 return;
401 }
402
403 const chid = characters.findIndex(x => x.avatar === memberAvatar);
404
405 if (chid === -1) {
406 console.warn(`WARN: No character found for group member ${memberAvatar} at index ${index}`);
407 return;
408 }
409
410 console.log(`Targeting group member ${memberAvatar} at index ${index}`);
411
412 return !full ? chid : {
413 id: chid,
414 avatar: memberAvatar,
415 name: characters.find(y => y.avatar === memberAvatar)?.name,
416 index: index,
417 };
418 }
419}
420
421/**
422 * Gets depth prompts for group members.
423 * @param {string} groupId Group ID
424 * @param {number} characterId Current Character ID
425 * @returns {{depth: number, text: string, role: string}[]} Array of depth prompts
426 */
427export function getGroupDepthPrompts(groupId, characterId) {
428 if (!groupId) {
429 return [];
430 }
431
432 console.debug('getGroupDepthPrompts entered for group: ', groupId);
433 const group = groups.find(x => x.id === groupId);
434
435 if (!group || !Array.isArray(group.members) || !group.members.length) {
436 return [];
437 }
438
439 if (group.generation_mode === group_generation_mode.SWAP) {
440 return [];
441 }
442
443 const depthPrompts = [];
444
445 for (const member of group.members) {
446 const index = characters.findIndex(x => x.avatar === member);
447 const character = characters[index];
448
449 if (index === -1 || !character) {
450 console.debug(`Skipping missing member: ${member}`);
451 continue;
452 }
453
454 if (group.disabled_members.includes(member) && characterId !== index) {
455 console.debug(`Skipping disabled group member: ${member}`);
456 continue;
457 }
458
459 const depthPromptText = baseChatReplace(character.data?.extensions?.depth_prompt?.prompt?.trim(), null, character.name) || '';
460 const depthPromptDepth = character.data?.extensions?.depth_prompt?.depth ?? depth_prompt_depth_default;
461 const depthPromptRole = character.data?.extensions?.depth_prompt?.role ?? depth_prompt_role_default;
462
463 if (depthPromptText) {
464 depthPrompts.push({ text: depthPromptText, depth: depthPromptDepth, role: depthPromptRole });
465 }
466 }
467
468 return depthPrompts;
469}
470
471/**
472 * Combines group members cards into a single string. Only for groups with generation mode set to APPEND or APPEND_DISABLED.
473 * @param {string} groupId Group ID
474 * @param {number} characterId Current Character ID
475 * @returns {{description: string, personality: string, scenario: string, mesExamples: string}} Group character cards combined
476 */
477export function getGroupCharacterCards(groupId, characterId) {
478 const lazy = getGroupCharacterCardsLazy(groupId, characterId);
479 if (!lazy) return null;
480
481 // Resolve all lazy fields into a plain object
482 return {
483 description: lazy.description,
484 personality: lazy.personality,
485 scenario: lazy.scenario,
486 mesExamples: lazy.mesExamples,
487 };
488}
489
490/**
491 * Returns group character cards with lazy evaluation.
492 * Each field is only processed when first accessed.
493 * @param {string} groupId Group ID
494 * @param {number} characterId Current Character ID
495 * @returns {{description: string, personality: string, scenario: string, mesExamples: string}} Group character cards with lazy getters
496 */
497export function getGroupCharacterCardsLazy(groupId, characterId) {
498 const group = groups.find(x => x.id === groupId);
499
500 // If no group cards should be generated, return null so caller knows to fall back
501 if (!group || !group?.generation_mode || !Array.isArray(group.members) || !group.members.length) {
502 return null;
503 }
504
505 /**
506 * Runs baseChatReplace on a text, with custom <FIELDNAME> replace
507 * @param {string} value Value to replace
508 * @param {string} fieldName Name of the field
509 * @param {string} characterName Name of the character
510 * @param {boolean} trim Whether to trim the value
511 * @returns {string} Replaced text
512 */
513 function customTransform(value, fieldName, characterName, trim) {
514 if (!value) return '';
515 value = value.replace(/<FIELDNAME>/gi, fieldName);
516 value = trim ? value.trim() : value;
517 return baseChatReplace(value, null, characterName);
518 }
519
520 /**
521 * Prepares text with prefix/suffix for a character field
522 * @param {string} value Value to replace
523 * @param {string} characterName Name of the character
524 * @param {string} fieldName Name of the field
525 * @param {function(string): string} [preprocess] Preprocess function
526 * @returns {string} Prepared text
527 */
528 function replaceAndPrepareForJoin(value, characterName, fieldName, preprocess = null) {
529 value = value?.trim() ?? '';
530 if (!value) return '';
531 if (typeof preprocess === 'function') {
532 value = preprocess(value);
533 }
534 const prefix = customTransform(group.generation_mode_join_prefix, fieldName, characterName, false);
535 const suffix = customTransform(group.generation_mode_join_suffix, fieldName, characterName, false);
536 value = customTransform(value, fieldName, characterName, true);
537 return `${prefix}${value}${suffix}`;
538 }
539
540 /**
541 * Collects and joins field values from all group members
542 * @param {string} fieldName Display name of the field
543 * @param {function(Character): string} getter Function to get field value from character
544 * @param {function(string): string} [preprocess] Optional preprocess function
545 * @returns {string} Combined field values
546 */
547 function collectField(fieldName, getter, preprocess = null) {
548 const values = [];
549 for (const member of group.members) {
550 const index = characters.findIndex(x => x.avatar === member);
551 const character = characters[index];
552 if (index === -1 || !character) continue;
553 if (group.disabled_members.includes(member) && characterId !== index && group.generation_mode !== group_generation_mode.APPEND_DISABLED) {
554 continue;
555 }
556 values.push(replaceAndPrepareForJoin(getter(character), character.name, fieldName, preprocess));
557 }
558 return values.filter(x => x.length).join('\n');
559 }
560
561 const scenarioOverride = String(chat_metadata.scenario || '');
562 const mesExamplesOverride = String(chat_metadata.mes_example || '');
563
564 return createLazyFields({
565 description: () => collectField('Description', c => c.description),
566 personality: () => collectField('Personality', c => c.personality),
567 scenario: () => baseChatReplace(scenarioOverride?.trim()) || collectField('Scenario', c => c.scenario),
568 mesExamples: () => baseChatReplace(mesExamplesOverride?.trim()) ||
569 collectField('Example Messages', c => c.mes_example, x => !x.startsWith('<START>') ? `<START>\n${x}` : x),
570 });
571}
572
573/**
574 * Gets the first message for a character.
575 * @param {Character} character Character object
576 * @returns {Promise<ChatMessage>} First message object
577 */
578async function getFirstCharacterMessage(character) {
579 let messageText = character.first_mes;
580
581 // if there are alternate greetings, pick one at random
582 if (Array.isArray(character.data?.alternate_greetings)) {
583 const messageTexts = [character.first_mes, ...character.data.alternate_greetings].filter(x => x);
584 messageText = messageTexts[Math.floor(Math.random() * messageTexts.length)];
585 }
586
587 // Allow extensions to change the first message
588 const eventArgs = { input: messageText, output: '', character: character };
589 await eventSource.emit(event_types.CHARACTER_FIRST_MESSAGE_SELECTED, eventArgs);
590 if (eventArgs.output) {
591 messageText = eventArgs.output;
592 }
593
594 const mes = {};
595 mes.is_user = false;
596 mes.is_system = false;
597 mes.name = character.name;
598 mes.send_date = getMessageTimeStamp();
599 mes.original_avatar = character.avatar;
600 mes.extra = { 'gen_id': Date.now() * Math.random() * 1000000 };
601 mes.mes = messageText
602 ? substituteParams(messageText.trim(), { name2Override: character.name })
603 : '';
604 mes.force_avatar =
605 character.avatar != 'none'
606 ? getThumbnailUrl('avatar', character.avatar)
607 : default_avatar;
608 return mes;
609}
610
611function resetSelectedGroup() {
612 selected_group = null;
613 is_group_generating = false;
614}
615
616/**
617 * Saves a group chat to the server.
618 * @param {string} groupId Group ID
619 * @param {boolean} shouldSaveGroup Whether to save the group after saving the chat
620 * @param {boolean} force Force the saving on integrity error
621 * @returns {Promise<void>} A promise that resolves when the group chat has been saved.
622 */
623async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
624 const group = groups.find(x => x.id == groupId);
625 if (!group) {
626 console.warn('Group not found', groupId);
627 return;
628 }
629 const chatId = group.chat_id;
630 group.date_last_chat = Date.now();
631 /** @type {ChatHeader} */
632 const chatHeader = {
633 chat_metadata: { ...chat_metadata },
634 user_name: 'unused',
635 character_name: 'unused',
636 };
637 const saveGroupChatRequest = await compressRequest({
638 method: 'POST',
639 headers: getRequestHeaders(),
640 body: JSON.stringify({ id: chatId, chat: [chatHeader, ...chat], force: force }),
641 });
642 const response = await fetch('/api/chats/group/save', saveGroupChatRequest);
643
644 if (!response.ok) {
645 const errorData = await response.json();
646 const isIntegrityError = errorData?.error === 'integrity' && !force;
647 if (!isIntegrityError) {
648 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group Chat could not be saved`);
649 console.error('Group chat could not be saved', response);
650 return;
651 }
652
653 const popupResult = await Popup.show.input(
654 t`ERROR: Chat integrity check failed while saving the file.`,
655 t`<p>After you click OK, the page will be reloaded to prevent data corruption.</p>
656 <p>To confirm an overwrite (and potentially <b>LOSE YOUR DATA</b>), enter <code>OVERWRITE</code> (in all caps) in the box below before clicking OK.</p>`,
657 '',
658 { okButton: 'OK', cancelButton: false },
659 );
660
661 const forceSaveConfirmed = popupResult === 'OVERWRITE';
662
663 if (!forceSaveConfirmed) {
664 console.warn('Chat integrity check failed, and user did not confirm the overwrite. Reloading the page.');
665 window.location.reload();
666 return;
667 }
668
669 await saveGroupChat(groupId, shouldSaveGroup, true);
670 }
671
672 if (shouldSaveGroup) {
673 await editGroup(groupId, false, false);
674 }
675}
676
677/**
678 * Renames a group member across all groups and their chats.
679 * @param {string} oldAvatar Old avatar name
680 * @param {string} newAvatar New avatar name
681 * @param {string} newName New character name
682 */
683export async function renameGroupMember(oldAvatar, newAvatar, newName) {
684 // Scan every group for our renamed character
685 for (const group of groups) {
686 try {
687 // Try finding the member by old avatar link
688 const memberIndex = group.members.findIndex(x => x == oldAvatar);
689
690 // Character was not present in the group...
691 if (memberIndex == -1) {
692 continue;
693 }
694
695 // Replace group member avatar id and save the changes
696 group.members[memberIndex] = newAvatar;
697 await editGroup(group.id, true, false);
698 console.log(`Renamed character ${newName} in group: ${group.name}`);
699
700 // Load all chats from this group
701 for (const chatId of group.chats) {
702 const messages = await loadGroupChat(chatId);
703
704 // Only save the chat if there were any changes to the chat content
705 let hadChanges = false;
706 // Chat shouldn't be empty
707 if (Array.isArray(messages) && messages.length) {
708 // Iterate over every chat message
709 for (const message of messages) {
710 // Skip the chat header
711 if (Object.hasOwn(message, 'chat_metadata')) {
712 continue;
713 }
714
715 // Only look at character messages
716 if (message.is_user || message.is_system) {
717 continue;
718 }
719
720 // Message belonged to the old-named character:
721 // Update name, avatar thumbnail URL and original avatar link
722 if (message.force_avatar && message.force_avatar.indexOf(encodeURIComponent(oldAvatar)) !== -1) {
723 message.name = newName;
724 message.force_avatar = message.force_avatar.replace(encodeURIComponent(oldAvatar), encodeURIComponent(newAvatar));
725 message.original_avatar = newAvatar;
726 hadChanges = true;
727 }
728 }
729
730 if (hadChanges) {
731 await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, messages, oldAvatar, newAvatar);
732
733 const saveChatRequest = await compressRequest({
734 method: 'POST',
735 headers: getRequestHeaders(),
736 body: JSON.stringify({ id: chatId, chat: [...messages] }),
737 });
738 const saveChatResponse = await fetch('/api/chats/group/save', saveChatRequest);
739
740 if (!saveChatResponse.ok) {
741 throw new Error('Group member could not be renamed');
742 }
743
744 console.log(`Renamed character ${newName} in group chat: ${chatId}`);
745 }
746 }
747 }
748 } catch (error) {
749 console.log(`An error during renaming the character ${newName} in group: ${group.name}`);
750 console.error(error);
751 }
752 }
753}
754
755/**
756 * Fetches all groups from the server and processes them.
757 */
758async function getGroups() {
759 const response = await fetch('/api/groups/all', {
760 method: 'POST',
761 headers: getRequestHeaders({ omitContentType: true }),
762 });
763
764 if (response.ok) {
765 /** @type {Group[]} */
766 const data = await response.json();
767 groups = data.slice();
768
769 // Convert groups to new format
770 for (const group of groups) {
771 if (typeof group.id === 'number') {
772 group.id = String(group.id);
773 }
774 if (group.disabled_members == undefined) {
775 group.disabled_members = [];
776 }
777 if (group.chat_id == undefined) {
778 group.chat_id = group.id;
779 group.chats = [group.id];
780 group.members = group.members
781 .map(x => characters.find(y => y.name == x)?.avatar)
782 .filter(x => x)
783 .filter(onlyUnique);
784 }
785 if (typeof group.chat_id === 'number') {
786 group.chat_id = String(group.chat_id);
787 }
788 if (Array.isArray(group.chats) && group.chats.some(x => typeof x === 'number')) {
789 group.chats = group.chats.map(x => String(x));
790 }
791 }
792 }
793}
794
795/**
796 * Gets a group UI block for the list.
797 * @param {Group} group Group object
798 * @returns {JQuery<HTMLElement>} jQuery element representing the group block
799 */
800export function getGroupBlock(group) {
801 let count = 0;
802 let namesList = [];
803
804 // Build inline name list
805 if (Array.isArray(group.members) && group.members.length) {
806 for (const member of group.members) {
807 const character = characters.find(x => x.avatar === member || x.name === member);
808 if (character) {
809 namesList.push(character.name);
810 count++;
811 }
812 }
813 }
814
815 const template = $('#group_list_template .group_select').clone();
816 template.data('id', group.id);
817 template.attr('data-grid', group.id);
818 template.find('.ch_name').text(group.name).attr('title', `[Group] ${group.name}`);
819 template.find('.group_fav_icon').css('display', 'none');
820 template.addClass(group.fav ? 'is_fav' : '');
821 template.find('.ch_fav').val(String(group.fav));
822 template.find('.group_select_counter').text(count + ' ' + (count != 1 ? t`characters` : t`character`));
823 template.find('.group_select_block_list').text(namesList.join(', '));
824
825 // Display inline tags
826 const tagsElement = template.find('.tags');
827 printTagList(tagsElement, { forEntityOrKey: group.id, tagOptions: { isCharacterList: true } });
828
829 const avatar = getGroupAvatar(group);
830 if (avatar) {
831 $(template).find('.avatar').replaceWith(avatar);
832 }
833
834 return template;
835}
836
837/**
838 * Updates the avatar display for a given group.
839 * @param {Group} group Group object
840 */
841function updateGroupAvatar(group) {
842 $('#group_avatar_preview').empty().append(getGroupAvatar(group));
843
844 $('.group_select').each(function () {
845 if ($(this).data('id') == group.id) {
846 $(this).find('.avatar').replaceWith(getGroupAvatar(group));
847 }
848 });
849
850 favsToHotswap();
851}
852
853/**
854 * Checks if a URL is a valid image URL.
855 * @param {string} url URL to check
856 * @returns {boolean} True if valid, false otherwise
857 */
858function isValidImageUrl(url) {
859 // check if empty dict
860 if (!url || Object.keys(url).length === 0) {
861 return false;
862 }
863 return isDataURL(url) || (url && (url.startsWith('user') || url.startsWith('/user')));
864}
865
866/**
867 * Gets a group avatar element.
868 * @param {Group} group Group object
869 * @returns {JQuery<HTMLElement>} Group avatar element
870 */
871function getGroupAvatar(group) {
872 if (!group) {
873 return $(`<div class="avatar"><img src="${default_avatar}"></div>`);
874 }
875 // if isDataURL or if it's a valid local file url
876 if (isValidImageUrl(group.avatar_url)) {
877 return $(`<div class="avatar" title="[Group] ${group.name}"><img src="${group.avatar_url}"></div>`);
878 }
879
880 const memberAvatars = [];
881 if (group && Array.isArray(group.members) && group.members.length) {
882 for (const member of group.members) {
883 const charIndex = characters.findIndex(x => x.avatar === member);
884 if (charIndex !== -1 && characters[charIndex].avatar !== 'none') {
885 const avatar = getThumbnailUrl('avatar', characters[charIndex].avatar);
886 memberAvatars.push(avatar);
887 }
888 if (memberAvatars.length === 4) {
889 break;
890 }
891 }
892 }
893
894 const avatarCount = memberAvatars.length;
895
896 if (avatarCount >= 1 && avatarCount <= 4) {
897 const groupAvatar = $(`#group_avatars_template .collage_${avatarCount}`).clone();
898
899 for (let i = 0; i < avatarCount; i++) {
900 groupAvatar.find(`.img_${i + 1}`).attr('src', memberAvatars[i]);
901 }
902
903 groupAvatar.attr('title', `[Group] ${group.name}`);
904 return groupAvatar;
905 }
906
907 // catch edge case where group had one member and that member is deleted
908 if (avatarCount === 0) {
909 return $('<div class="missing-avatar fa-solid fa-user-slash"></div>');
910 }
911
912 // default avatar
913 const groupAvatar = $('#group_avatars_template .collage_1').clone();
914 groupAvatar.find('.img_1').attr('src', group.avatar_url || system_avatar);
915 groupAvatar.attr('title', `[Group] ${group.name}`);
916 return groupAvatar;
917}
918
919/**
920 * Gets chat IDs for a group.
921 * @param {string} groupId Group ID
922 * @returns {string[]} Array of chat IDs
923 */
924function getGroupChatNames(groupId) {
925 const group = groups.find(x => x.id === groupId);
926
927 if (!group) {
928 return [];
929 }
930
931 const names = [];
932 for (const chatId of group.chats) {
933 names.push(chatId);
934 }
935 return names;
936}
937
938/**
939 * Generates text for the group chat by queueing members according to the activation strategy.
940 * @param {boolean} byAutoMode If the generation was triggered by the auto mode.
941 * @param {string?} type Generation type
942 * @param {object} params Additional Generate parameters
943 * @returns {Promise<string|void>} Generated text or nothing if no generation occurred
944 */
945async function generateGroupWrapper(byAutoMode, type = null, params = {}) {
946 function throwIfAborted() {
947 if (params.signal instanceof AbortSignal && params.signal.aborted) {
948 throw new Error('AbortSignal was fired. Group generation stopped');
949 }
950 }
951
952 if (online_status === 'no_connection') {
953 is_group_generating = false;
954 setSendButtonState(false);
955 return Promise.resolve();
956 }
957
958 if (is_group_generating) {
959 return Promise.resolve();
960 }
961
962 // Auto-navigate back to group menu
963 if (menu_type !== 'group_edit') {
964 select_group_chats(selected_group, false);
965 await delay(1);
966 }
967
968 /** @type {any} Caution: JS war crimes ahead */
969 let textResult = '';
970 const group = groups.find((x) => x.id === selected_group);
971
972 if (!group || !Array.isArray(group.members) || !group.members.length) {
973 sendSystemMessage(system_message_types.EMPTY, '', { isSmallSys: true });
974 return Promise.resolve();
975 }
976
977 try {
978 await unshallowGroupMembers(selected_group);
979
980 throwIfAborted();
981 hideSwipeButtons();
982 is_group_generating = true;
983 setCharacterName('');
984 setCharacterId(undefined);
985 const userInput = String($('#send_textarea').val());
986
987 // id of this specific batch for regeneration purposes
988 group_generation_id = Date.now();
989 const lastMessage = chat[chat.length - 1];
990 let activationText = '';
991 let isUserInput = false;
992
993 if (userInput?.length && !byAutoMode) {
994 isUserInput = true;
995 activationText = userInput;
996 } else {
997 if (lastMessage && !lastMessage.is_system) {
998 activationText = lastMessage.mes;
999 }
1000 }
1001
1002 const activationStrategy = Number(group.activation_strategy ?? group_activation_strategy.NATURAL);
1003 const enabledMembers = group.members.filter(x => !group.disabled_members.includes(x));
1004 let activatedMembers = [];
1005
1006 if (params && typeof params.force_chid == 'number') {
1007 activatedMembers = [params.force_chid];
1008 } else if (type === 'quiet') {
1009 activatedMembers = activateSwipe(group.members, { allowSystem: true }).slice(0, 1);
1010
1011 if (activatedMembers.length === 0) {
1012 activatedMembers = activateListOrder(group.members.slice(0, 1));
1013 }
1014 } else if (type === 'swipe' || type === 'continue') {
1015 activatedMembers = activateSwipe(group.members, { allowSystem: false });
1016
1017 if (activatedMembers.length === 0) {
1018 toastr.warning(t`Deleted group member swiped. To get a reply, add them back to the group.`);
1019 throw new Error('Deleted group member swiped');
1020 }
1021 } else if (type === 'impersonate') {
1022 activatedMembers = activateImpersonate(group.members);
1023 } else if (activationStrategy === group_activation_strategy.NATURAL) {
1024 activatedMembers = activateNaturalOrder(enabledMembers, activationText, lastMessage, group.allow_self_responses, isUserInput);
1025 } else if (activationStrategy === group_activation_strategy.LIST) {
1026 activatedMembers = activateListOrder(enabledMembers);
1027 } else if (activationStrategy === group_activation_strategy.POOLED) {
1028 activatedMembers = activatePooledOrder(enabledMembers, lastMessage, isUserInput);
1029 } else if (activationStrategy === group_activation_strategy.MANUAL && !isUserInput) {
1030 activatedMembers = shuffle(enabledMembers).slice(0, 1).map(x => characters.findIndex(y => y.avatar === x)).filter(x => x !== -1);
1031 }
1032
1033 if (activatedMembers.length === 0) {
1034 //toastr.warning('All group members are disabled. Enable at least one to get a reply.');
1035
1036 // Send user message as is
1037 const bias = getBiasStrings(userInput, type);
1038 await sendMessageAsUser(userInput, bias.messageBias);
1039 await saveChatConditional();
1040 $('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true }));
1041 }
1042 groupChatQueueOrder = new Map();
1043
1044 if (power_user.show_group_chat_queue) {
1045 for (let i = 0; i < activatedMembers.length; ++i) {
1046 groupChatQueueOrder.set(characters[activatedMembers[i]].avatar, i + 1);
1047 }
1048 }
1049 await eventSource.emit(event_types.GROUP_WRAPPER_STARTED, { selected_group, type });
1050 // now the real generation begins: cycle through every activated character
1051 for (const chId of activatedMembers) {
1052 throwIfAborted();
1053 deactivateSendButtons();
1054 setCharacterId(chId);
1055 setCharacterName(characters[chId].name);
1056 if (power_user.show_group_chat_queue) {
1057 printGroupMembers();
1058 }
1059 await eventSource.emit(event_types.GROUP_MEMBER_DRAFTED, chId);
1060
1061 // Wait for generation to finish
1062 const generateType = ['swipe', 'impersonate', 'quiet', 'continue'].includes(type) ? type : 'normal';
1063 textResult = await Generate(generateType, { automatic_trigger: byAutoMode, ...(params || {}) });
1064 let messageChunk = textResult?.messageChunk;
1065
1066 if (messageChunk) {
1067 while (shouldAutoContinue(messageChunk, type === 'impersonate')) {
1068 textResult = await Generate('continue', { automatic_trigger: byAutoMode, ...(params || {}) });
1069 messageChunk = textResult?.messageChunk;
1070 }
1071 }
1072 if (power_user.show_group_chat_queue) {
1073 groupChatQueueOrder.delete(characters[chId].avatar);
1074 groupChatQueueOrder.forEach((value, key, map) => map.set(key, value - 1));
1075 }
1076 }
1077 } finally {
1078 is_group_generating = false;
1079 setSendButtonState(false);
1080 setCharacterId(undefined);
1081 if (power_user.show_group_chat_queue) {
1082 groupChatQueueOrder = new Map();
1083 printGroupMembers();
1084 }
1085 setCharacterName('');
1086 activateSendButtons();
1087 showSwipeButtons();
1088 await eventSource.emit(event_types.GROUP_WRAPPER_FINISHED, { selected_group, type });
1089 }
1090
1091 return Promise.resolve(textResult);
1092}
1093
1094/**
1095 * Gets the generation ID of the last chat message.
1096 * @returns {number|null} Generation ID or null
1097 */
1098function getLastMessageGenerationId() {
1099 let generationId = null;
1100 if (chat.length > 0) {
1101 const lastMes = chat[chat.length - 1];
1102 if (!lastMes.is_user && !lastMes.is_system && lastMes.extra) {
1103 generationId = lastMes.extra.gen_id;
1104 }
1105 }
1106 return generationId;
1107}
1108
1109/**
1110 * Activate group chat members for 'impersonate' generation type.
1111 * @param {string[]} members Array of group member avatar ids
1112 * @returns {number[]} Array of character ids
1113 */
1114function activateImpersonate(members) {
1115 const randomIndex = Math.floor(Math.random() * members.length);
1116 const activatedMembers = [members[randomIndex]];
1117 const memberIds = activatedMembers
1118 .map((x) => characters.findIndex((y) => y.avatar === x))
1119 .filter((x) => x !== -1);
1120 return memberIds;
1121}
1122
1123/**
1124 * Activates a group member based on the last message.
1125 * @param {string[]} members Array of group member avatar ids
1126 * @param {Object} [options] Options object
1127 * @param {boolean} [options.allowSystem] Whether to allow system messages
1128 * @returns {number[]} Array of character ids
1129 */
1130function activateSwipe(members, { allowSystem = false } = {}) {
1131 let activatedNames = [];
1132 const lastMessage = chat[chat.length - 1];
1133
1134 if (!lastMessage) {
1135 return [];
1136 }
1137
1138 if (lastMessage.is_user || (!allowSystem && lastMessage.is_system) || lastMessage.extra?.type === system_message_types.NARRATOR) {
1139 for (const message of chat.slice().reverse()) {
1140 if (message.is_user || (!allowSystem && message.is_system) || message.extra?.type === system_message_types.NARRATOR) {
1141 continue;
1142 }
1143
1144 if (message.original_avatar) {
1145 activatedNames.push(message.original_avatar);
1146 break;
1147 }
1148 }
1149
1150 if (activatedNames.length === 0) {
1151 activatedNames.push(shuffle(members.slice())[0]);
1152 }
1153 }
1154
1155 // pre-update group chat swipe
1156 if (!lastMessage.original_avatar) {
1157 const matches = characters.filter(x => x.name == lastMessage.name);
1158
1159 for (const match of matches) {
1160 if (members.includes(match.avatar)) {
1161 activatedNames.push(match.avatar);
1162 break;
1163 }
1164 }
1165 } else {
1166 activatedNames.push(lastMessage.original_avatar);
1167 }
1168
1169 const memberIds = activatedNames
1170 .map((x) => characters.findIndex((y) => y.avatar === x))
1171 .filter((x) => x !== -1);
1172 return memberIds;
1173}
1174
1175/**
1176 * Activate group members for the list activation order.
1177 * @param {string[]} members Array of group member avatar ids
1178 * @returns {number[]} Array of character ids
1179 */
1180function activateListOrder(members) {
1181 let activatedMembers = members.filter(onlyUnique);
1182
1183 // map to character ids
1184 const memberIds = activatedMembers
1185 .map((x) => characters.findIndex((y) => y.avatar === x))
1186 .filter((x) => x !== -1);
1187 return memberIds;
1188}
1189
1190/**
1191 * Activate group members based on the last message.
1192 * @param {string[]} members List of member avatars
1193 * @param {Object} lastMessage Last message
1194 * @param {boolean} isUserInput Whether the user has input text
1195 * @returns {number[]} List of character ids
1196 */
1197function activatePooledOrder(members, lastMessage, isUserInput) {
1198 /** @type {string} */
1199 let activatedMember = null;
1200 /** @type {string[]} */
1201 const spokenSinceUser = [];
1202
1203 for (const message of chat.slice().reverse()) {
1204 if (message.is_user || isUserInput) {
1205 break;
1206 }
1207
1208 if (message.is_system || message.extra?.type === system_message_types.NARRATOR) {
1209 continue;
1210 }
1211
1212 if (message.original_avatar) {
1213 spokenSinceUser.push(message.original_avatar);
1214 }
1215 }
1216
1217 const haveNotSpoken = members.filter(x => !spokenSinceUser.includes(x));
1218
1219 if (haveNotSpoken.length) {
1220 activatedMember = haveNotSpoken[Math.floor(Math.random() * haveNotSpoken.length)];
1221 }
1222
1223 if (activatedMember === null) {
1224 const lastMessageAvatar = members.length > 1 && lastMessage && !lastMessage.is_user && lastMessage.original_avatar;
1225 const randomPool = lastMessageAvatar ? members.filter(x => x !== lastMessage.original_avatar) : members;
1226 activatedMember = randomPool[Math.floor(Math.random() * randomPool.length)];
1227 }
1228
1229 const memberId = characters.findIndex(y => y.avatar === activatedMember);
1230 return memberId !== -1 ? [memberId] : [];
1231}
1232
1233/**
1234 * Activate group members for the natural activation order.
1235 * @param {string[]} members Array of group member avatar ids
1236 * @param {string} input User input that triggered the generation
1237 * @param {ChatMessage} lastMessage Last message in the chat
1238 * @param {boolean} allowSelfResponses If the group allows self-responses
1239 * @param {boolean} isUserInput If the generation was triggered by user input
1240 * @returns {number[]} Array of character ids
1241 */
1242function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, isUserInput) {
1243 let activatedMembers = [];
1244
1245 // prevents the same character from speaking twice
1246 let bannedUser = !isUserInput && lastMessage && !lastMessage.is_user && lastMessage.name;
1247
1248 // ...unless allowed to do so
1249 if (allowSelfResponses) {
1250 bannedUser = undefined;
1251 }
1252
1253 // find mentions (excluding self)
1254 if (input && input.length) {
1255 for (let inputWord of extractAllWords(input)) {
1256 for (let member of members) {
1257 const character = characters.find(x => x.avatar === member);
1258
1259 if (!character || character.name === bannedUser) {
1260 continue;
1261 }
1262
1263 if (extractAllWords(character.name).includes(inputWord)) {
1264 activatedMembers.push(member);
1265 break;
1266 }
1267 }
1268 }
1269 }
1270
1271 const chattyMembers = [];
1272 // activation by talkativeness (in shuffled order, except banned)
1273 const shuffledMembers = shuffle([...members]);
1274 for (let member of shuffledMembers) {
1275 const character = characters.find((x) => x.avatar === member);
1276
1277 if (!character || character.name === bannedUser) {
1278 continue;
1279 }
1280
1281 const rollValue = Math.random();
1282 const talkativeness = isNaN(character.talkativeness)
1283 ? talkativeness_default
1284 : Number(character.talkativeness);
1285 if (talkativeness >= rollValue) {
1286 activatedMembers.push(member);
1287 }
1288 if (talkativeness > 0) {
1289 chattyMembers.push(member);
1290 }
1291 }
1292
1293 // pick 1 at random if no one was activated
1294 let retries = 0;
1295 // try to limit the selected random character to those with talkativeness > 0
1296 const randomPool = chattyMembers.length > 0 ? chattyMembers : members;
1297 while (activatedMembers.length === 0 && ++retries <= randomPool.length) {
1298 const randomIndex = Math.floor(Math.random() * randomPool.length);
1299 const character = characters.find((x) => x.avatar === randomPool[randomIndex]);
1300
1301 if (!character) {
1302 continue;
1303 }
1304
1305 activatedMembers.push(randomPool[randomIndex]);
1306 }
1307
1308 // de-duplicate array of character avatars
1309 activatedMembers = activatedMembers.filter(onlyUnique);
1310
1311 // map to character ids
1312 const memberIds = activatedMembers
1313 .map((x) => characters.findIndex((y) => y.avatar === x))
1314 .filter((x) => x !== -1);
1315 return memberIds;
1316}
1317
1318/**
1319 * Deletes a group from the server by ID.
1320 * @param {string} id Group ID to delete
1321 * @returns {Promise<void>} Promise that resolves when the group is deleted
1322 */
1323async function deleteGroup(id) {
1324 const group = groups.find((x) => x.id === id);
1325
1326 const response = await fetch('/api/groups/delete', {
1327 method: 'POST',
1328 headers: getRequestHeaders(),
1329 body: JSON.stringify({ id: id }),
1330 });
1331
1332 if (group && Array.isArray(group.chats)) {
1333 for (const chatId of group.chats) {
1334 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatId);
1335 }
1336 }
1337
1338 if (response.ok) {
1339 await clearChat();
1340 selected_group = null;
1341 delete tag_map[id];
1342 resetChatState();
1343 await printMessages();
1344 await getCharacters();
1345
1346 select_rm_info('group_delete', id);
1347
1348 $('#rm_button_selected_ch').children('h2').text('');
1349 }
1350}
1351
1352/**
1353 * Edits a group by ID.
1354 * @param {string} id Group ID to edit
1355 * @param {boolean} immediately Whether to save immediately
1356 * @param {boolean} reload Whether to reload the groups after saving
1357 * @returns {Promise<void>} Promise that resolves when the group is edited
1358 */
1359export async function editGroup(id, immediately, reload = true) {
1360 let group = groups.find((x) => x.id === id);
1361
1362 if (!group) {
1363 return;
1364 }
1365
1366 if (immediately) {
1367 return await _save(group, reload);
1368 }
1369
1370 saveGroupDebounced(group, reload);
1371}
1372
1373/**
1374 * Unshallows all definitions of group members.
1375 * @param {string} groupId Id of the group
1376 * @returns {Promise<void>} Promise that resolves when all group members are unshallowed
1377 */
1378export async function unshallowGroupMembers(groupId) {
1379 const group = groups.find(x => x.id == groupId);
1380 if (!group) {
1381 return;
1382 }
1383 const members = group.members;
1384 if (!Array.isArray(members)) {
1385 return;
1386 }
1387 for (const member of members) {
1388 const index = characters.findIndex(x => x.avatar === member);
1389 if (index === -1) {
1390 continue;
1391 }
1392 await unshallowCharacter(String(index));
1393 }
1394}
1395
1396let groupAutoModeAbortController = null;
1397
1398async function groupChatAutoModeWorker() {
1399 if (!is_group_automode_enabled || online_status === 'no_connection') {
1400 return;
1401 }
1402
1403 if (!selected_group || is_send_press || is_group_generating) {
1404 return;
1405 }
1406
1407 const group = groups.find((x) => x.id === selected_group);
1408
1409 if (!group || !Array.isArray(group.members) || !group.members.length) {
1410 return;
1411 }
1412
1413 groupAutoModeAbortController = new AbortController();
1414 await generateGroupWrapper(true, 'auto', { signal: groupAutoModeAbortController.signal });
1415}
1416
1417/**
1418 * Modifies a group member by adding or removing them.
1419 * @param {string} groupId Group ID
1420 * @param {JQuery<HTMLElement>} groupMember Group member element
1421 * @param {boolean} isDelete If true, removes the member; otherwise adds the member
1422 */
1423async function modifyGroupMember(groupId, groupMember, isDelete) {
1424 const id = groupMember.data('id');
1425 const thisGroup = groups.find((x) => x.id == groupId);
1426 const membersArray = thisGroup?.members ?? newGroupMembers;
1427
1428 if (isDelete) {
1429 const index = membersArray.findIndex((x) => x === id);
1430 if (index !== -1) {
1431 membersArray.splice(membersArray.indexOf(id), 1);
1432 }
1433 } else {
1434 membersArray.unshift(id);
1435 }
1436
1437 if (openGroupId) {
1438 await unshallowGroupMembers(openGroupId);
1439 await editGroup(openGroupId, false, false);
1440 updateGroupAvatar(thisGroup);
1441 }
1442
1443 printGroupCandidates();
1444 printGroupMembers();
1445
1446 // Refresh the tag filters for both lists to reflect any new tags
1447 printTagFilters(tag_filter_type.group_candidates_list);
1448 printTagFilters(tag_filter_type.group_members_list);
1449
1450 const groupHasMembers = getGroupCharacters({ doFilter: false, onlyMembers: true }).length > 0;
1451 $('#rm_group_submit').prop('disabled', !groupHasMembers);
1452}
1453
1454/**
1455 * Reorders a group member up or down.
1456 * @param {string} groupId Group ID
1457 * @param {JQuery<HTMLElement>} groupMember Group member element
1458 * @param {string} direction Direction to move the member ('up' or 'down')
1459 * @returns {Promise<void>} Promise that resolves when the member has been reordered
1460 */
1461async function reorderGroupMember(groupId, groupMember, direction) {
1462 const id = groupMember.data('id');
1463 const thisGroup = groups.find((x) => x.id == groupId);
1464 const memberArray = thisGroup?.members ?? newGroupMembers;
1465
1466 const indexOf = memberArray.indexOf(id);
1467 if (direction == 'down') {
1468 const next = memberArray[indexOf + 1];
1469 if (next) {
1470 memberArray[indexOf + 1] = memberArray[indexOf];
1471 memberArray[indexOf] = next;
1472 }
1473 }
1474 if (direction == 'up') {
1475 const prev = memberArray[indexOf - 1];
1476 if (prev) {
1477 memberArray[indexOf - 1] = memberArray[indexOf];
1478 memberArray[indexOf] = prev;
1479 }
1480 }
1481
1482 printGroupMembers();
1483
1484 // Existing groups need to modify members list
1485 if (openGroupId) {
1486 await editGroup(groupId, false, false);
1487 updateGroupAvatar(thisGroup);
1488 }
1489}
1490
1491async function onGroupActivationStrategyInput(e) {
1492 if (openGroupId) {
1493 let _thisGroup = groups.find((x) => x.id == openGroupId);
1494 _thisGroup.activation_strategy = Number(e.target.value);
1495 await editGroup(openGroupId, false, false);
1496 }
1497}
1498
1499async function onGroupGenerationModeInput(e) {
1500 if (openGroupId) {
1501 let _thisGroup = groups.find((x) => x.id == openGroupId);
1502 _thisGroup.generation_mode = Number(e.target.value);
1503 await editGroup(openGroupId, false, false);
1504
1505 toggleHiddenControls(_thisGroup);
1506 }
1507}
1508
1509async function onGroupAutoModeDelayInput(e) {
1510 if (openGroupId) {
1511 let _thisGroup = groups.find((x) => x.id == openGroupId);
1512 _thisGroup.auto_mode_delay = Number(e.target.value);
1513 await editGroup(openGroupId, false, false);
1514 setAutoModeWorker();
1515 }
1516}
1517
1518async function onGroupGenerationModeTemplateInput(e) {
1519 if (openGroupId) {
1520 let _thisGroup = groups.find((x) => x.id == openGroupId);
1521 const prop = $(e.target).attr('setting');
1522 _thisGroup[prop] = String(e.target.value);
1523 await editGroup(openGroupId, false, false);
1524 }
1525}
1526
1527async function onGroupNameInput() {
1528 if (openGroupId) {
1529 let _thisGroup = groups.find((x) => x.id == openGroupId);
1530 _thisGroup.name = $(this).val();
1531 $('#rm_button_selected_ch').children('h2').text(_thisGroup.name);
1532 await editGroup(openGroupId, false);
1533 }
1534}
1535
1536/**
1537 * Checks if a character with the given avatar ID is a member of the group.
1538 * @param {Group} group Group object
1539 * @param {string} avatarId Avatar ID to check
1540 * @returns {boolean} True if the avatar is a member of the group, false otherwise
1541 */
1542function isGroupMember(group, avatarId) {
1543 if (group && Array.isArray(group.members)) {
1544 return group.members.includes(avatarId);
1545 } else {
1546 return newGroupMembers.includes(avatarId);
1547 }
1548}
1549
1550/**
1551 * Gets group characters based on filters.
1552 * @param {object} param
1553 * @param {boolean} [param.doFilter=false] Whether to apply filters
1554 * @param {boolean} [param.onlyMembers=false] Whether to include only group members
1555 * @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects
1556 */
1557function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {
1558 function applyFilterAndSort(results, filter, filterSelector) {
1559 let filtered = results;
1560 if (doFilter) {
1561 filtered = filter.applyFilters(filtered);
1562 }
1563 const useFilterOrder = doFilter && !!$(filterSelector).val();
1564 sortEntitiesList(filtered, useFilterOrder, filter);
1565 filter.clearFuzzySearchCaches();
1566 return filtered;
1567 }
1568
1569 function handleMembers(results, thisGroup) {
1570 const membersArray = thisGroup?.members ?? newGroupMembers;
1571
1572 // Create index map for O(1) lookups in member sort function
1573 // (separate from characterIndexMap which maps character objects to their array indices)
1574 const memberIndexMap = new Map(membersArray.map((avatar, index) => [avatar, index]));
1575
1576 function sortMembersFn(a, b) {
1577 const aIndex = memberIndexMap.get(a.item.avatar) ?? -1;
1578 const bIndex = memberIndexMap.get(b.item.avatar) ?? -1;
1579 return aIndex - bIndex;
1580 }
1581
1582 // Apply manual member sort before filter and sort
1583 let filtered = results;
1584 if (doFilter) {
1585 filtered = groupMembersFilter.applyFilters(filtered);
1586 }
1587 filtered.sort(sortMembersFn);
1588
1589 // Apply conditional filter-based sort and cleanup
1590 const useFilterOrder = doFilter && !!$('#rm_group_members_filter').val();
1591 if (useFilterOrder) {
1592 sortEntitiesList(filtered, useFilterOrder, groupMembersFilter);
1593 }
1594 groupMembersFilter.clearFuzzySearchCaches();
1595 return filtered;
1596 }
1597
1598 const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
1599
1600 // Create index map for O(1) lookups when mapping characters to their array indices
1601 // (separate from memberIndexMap used later for sorting members by their group order)
1602 const characterIndexMap = new Map(characters.map((char, index) => [char, index]));
1603
1604 const results = characters
1605 .filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers)
1606 .map((x) => ({ item: x, id: characterIndexMap.get(x), type: 'character' }));
1607
1608 // Early return for candidates (non-members)
1609 if (!onlyMembers) {
1610 return applyFilterAndSort(results, groupCandidatesFilter, '#rm_group_filter');
1611 }
1612
1613 // Handle members with manual sort capability
1614 return handleMembers(results, thisGroup);
1615}
1616
1617function printGroupCandidates() {
1618 const storageKey = 'GroupCandidates_PerPage';
1619 const pageSize = Number(accountStorage.getItem(storageKey)) || 5;
1620 const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000];
1621 $('#rm_group_add_members_pagination').pagination({
1622 dataSource: getGroupCharacters({ doFilter: true, onlyMembers: false }),
1623 pageRange: 1,
1624 position: 'top',
1625 showPageNumbers: false,
1626 prevText: '<',
1627 nextText: '>',
1628 formatNavigator: PAGINATION_TEMPLATE,
1629 formatSizeChanger: renderPaginationDropdown(pageSize, sizeChangerOptions),
1630 showNavigator: true,
1631 showSizeChanger: true,
1632 pageSize,
1633 afterSizeSelectorChange: function (e, size) {
1634 accountStorage.setItem(storageKey, e.target.value);
1635 paginationDropdownChangeHandler(e, size);
1636 },
1637 callback: function (data) {
1638 $('#rm_group_add_members').empty();
1639 for (const i of data) {
1640 $('#rm_group_add_members').append(getGroupCharacterBlock(i.item));
1641 }
1642 localizePagination($('#rm_group_add_members_pagination'));
1643 },
1644 });
1645}
1646
1647function printGroupMembers() {
1648 const storageKey = 'GroupMembers_PerPage';
1649 $('.rm_group_members_pagination').each(function () {
1650 let that = this;
1651 const pageSize = Number(accountStorage.getItem(storageKey)) || 5;
1652 const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000];
1653 $(this).pagination({
1654 dataSource: getGroupCharacters({ doFilter: true, onlyMembers: true }),
1655 pageRange: 1,
1656 position: 'top',
1657 showPageNumbers: false,
1658 prevText: '<',
1659 nextText: '>',
1660 formatNavigator: PAGINATION_TEMPLATE,
1661 showNavigator: true,
1662 showSizeChanger: true,
1663 formatSizeChanger: renderPaginationDropdown(pageSize, sizeChangerOptions),
1664 pageSize,
1665 afterSizeSelectorChange: function (e, size) {
1666 accountStorage.setItem(storageKey, e.target.value);
1667 paginationDropdownChangeHandler(e, size);
1668 },
1669 callback: function (data) {
1670 $('.rm_group_members').empty();
1671 for (const i of data) {
1672 $('.rm_group_members').append(getGroupCharacterBlock(i.item));
1673 }
1674 localizePagination($(that));
1675 },
1676 });
1677 });
1678}
1679
1680/**
1681 * Creates a jQuery element representing a group character block.
1682 * @param {Character} character Character object
1683 * @returns {JQuery<HTMLElement>} jQuery element representing the group character block
1684 */
1685function getGroupCharacterBlock(character) {
1686 const avatar = getThumbnailUrl('avatar', character.avatar);
1687 const template = $('#group_member_template .group_member').clone();
1688 const isFav = !!character.fav || character.fav == 'true';
1689 template.data('id', character.avatar);
1690 template.find('.avatar img').attr({ 'src': avatar, 'title': character.avatar });
1691 template.find('.ch_name').text(character.name);
1692 template.attr('data-chid', characters.indexOf(character));
1693 template.find('.ch_fav').val(String(isFav));
1694 template.toggleClass('is_fav', isFav);
1695
1696 const auxFieldName = power_user.aux_field || 'character_version';
1697 const auxFieldValue = (character.data && character.data[auxFieldName]) || '';
1698 if (auxFieldValue) {
1699 template.find('.character_version').text(auxFieldValue);
1700 } else {
1701 template.find('.character_version').hide();
1702 }
1703
1704 let queuePosition = groupChatQueueOrder.get(character.avatar);
1705 if (queuePosition) {
1706 template.find('.queue_position').text(queuePosition);
1707 template.toggleClass('is_queued', queuePosition > 1);
1708 template.toggleClass('is_active', queuePosition === 1);
1709 }
1710
1711 template.toggleClass('disabled', isGroupMemberDisabled(character.avatar));
1712
1713 // Display inline tags
1714 const tagsElement = template.find('.tags');
1715 printTagList(tagsElement, { forEntityOrKey: characters.indexOf(character), tagOptions: { isCharacterList: true } });
1716
1717 if (!openGroupId) {
1718 template.find('[data-action="speak"]').hide();
1719 template.find('[data-action="enable"]').hide();
1720 template.find('[data-action="disable"]').hide();
1721 }
1722
1723 return template;
1724}
1725
1726/**
1727 * Checks if a group member is disabled.
1728 * @param {string} avatarId Avatar ID of the group member
1729 * @returns {boolean} True if the group member is disabled, false otherwise
1730 */
1731function isGroupMemberDisabled(avatarId) {
1732 const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
1733 return Boolean(thisGroup && thisGroup.disabled_members.includes(avatarId));
1734}
1735
1736async function onDeleteGroupClick() {
1737 if (!openGroupId) {
1738 toastr.warning(t`Currently no group selected.`);
1739 return;
1740 }
1741 if (is_group_generating) {
1742 toastr.warning(t`Not so fast! Wait for the characters to stop typing before deleting the group.`);
1743 return;
1744 }
1745
1746 const confirm = await Popup.show.confirm(t`Delete the group?`, '<p>' + t`This will also delete all your chats with that group. If you want to delete a single conversation, select a "View past chats" option in the lower left menu.` + '</p>');
1747 if (confirm) {
1748 deleteGroup(openGroupId);
1749 }
1750}
1751
1752async function onFavoriteGroupClick() {
1753 updateFavButtonState(!fav_grp_checked);
1754 if (openGroupId) {
1755 let _thisGroup = groups.find((x) => x.id == openGroupId);
1756 _thisGroup.fav = fav_grp_checked;
1757 await editGroup(openGroupId, false, false);
1758 favsToHotswap();
1759 }
1760}
1761
1762async function onGroupSelfResponsesClick() {
1763 if (openGroupId) {
1764 let _thisGroup = groups.find((x) => x.id == openGroupId);
1765 const value = $(this).prop('checked');
1766 _thisGroup.allow_self_responses = value;
1767 await editGroup(openGroupId, false, false);
1768 }
1769}
1770
1771async function onHideMutedSpritesClick(value) {
1772 if (openGroupId) {
1773 let _thisGroup = groups.find((x) => x.id == openGroupId);
1774 _thisGroup.hideMutedSprites = value;
1775 console.log(`_thisGroup.hideMutedSprites = ${_thisGroup.hideMutedSprites}`);
1776 await editGroup(openGroupId, false, false);
1777 await eventSource.emit(event_types.GROUP_UPDATED);
1778 }
1779}
1780
1781/**
1782 * Toggles the visibility of hidden controls based on the group's generation mode.
1783 * @param {Group} group Group object
1784 * @param {number|null} generationMode Generation mode, or null to use the group's current generation mode
1785 */
1786function toggleHiddenControls(group, generationMode = null) {
1787 const isJoin = [group_generation_mode.APPEND, group_generation_mode.APPEND_DISABLED].includes(generationMode ?? group?.generation_mode);
1788 $('#rm_group_generation_mode_join_prefix').parent().toggle(isJoin);
1789 $('#rm_group_generation_mode_join_suffix').parent().toggle(isJoin);
1790
1791 if (!CSS.supports('field-sizing', 'content')) {
1792 initScrollHeight($('#rm_group_generation_mode_join_prefix'));
1793 initScrollHeight($('#rm_group_generation_mode_join_suffix'));
1794 }
1795}
1796
1797/**
1798 * Opens a group creation/editing right menu.
1799 * @param {string|null} groupId ID of the group to select or null if creating a new group
1800 * @param {boolean} skipAnimation If true, skips the animation when selecting the group
1801 */
1802function select_group_chats(groupId, skipAnimation) {
1803 openGroupId = groupId;
1804 newGroupMembers = [];
1805 const group = openGroupId && groups.find((x) => x.id == openGroupId);
1806 const groupName = group?.name ?? '';
1807 const replyStrategy = Number(group?.activation_strategy ?? group_activation_strategy.NATURAL);
1808 const generationMode = Number(group?.generation_mode ?? group_generation_mode.SWAP);
1809
1810 setMenuType(group ? 'group_edit' : 'group_create');
1811 $('#group_avatar_preview').empty().append(getGroupAvatar(group));
1812 $('#rm_group_restore_avatar').toggle(!!group && isValidImageUrl(group.avatar_url));
1813 $('#rm_group_filter').val('').trigger('input');
1814 $('#rm_group_members_filter').val('').trigger('input');
1815 $('#rm_group_activation_strategy').val(replyStrategy);
1816 $(`#rm_group_activation_strategy option[value="${replyStrategy}"]`).prop('selected', true);
1817 $('#rm_group_generation_mode').val(generationMode);
1818 $(`#rm_group_generation_mode option[value="${generationMode}"]`).prop('selected', true);
1819 $('#rm_group_chat_name').val(groupName);
1820
1821 if (!skipAnimation) {
1822 selectRightMenuWithAnimation('rm_group_chats_block');
1823 }
1824
1825 // render tags
1826 applyTagsOnGroupSelect(groupId);
1827
1828 // render characters list
1829 printGroupCandidates();
1830 printGroupMembers();
1831
1832 const groupHasMembers = !!$('#rm_group_members').children().length;
1833 $('#rm_group_submit').prop('disabled', !groupHasMembers);
1834 $('#rm_group_allow_self_responses').prop('checked', group && group.allow_self_responses);
1835 $('#rm_group_hidemutedsprites').prop('checked', group && group.hideMutedSprites);
1836 $('#rm_group_automode_delay').val(group?.auto_mode_delay ?? DEFAULT_AUTO_MODE_DELAY);
1837
1838 $('#rm_group_generation_mode_join_prefix').val(group?.generation_mode_join_prefix ?? '').attr('setting', 'generation_mode_join_prefix');
1839 $('#rm_group_generation_mode_join_suffix').val(group?.generation_mode_join_suffix ?? '').attr('setting', 'generation_mode_join_suffix');
1840 toggleHiddenControls(group, generationMode);
1841
1842 // bottom buttons
1843 if (openGroupId) {
1844 $('#rm_group_submit').hide();
1845 $('#rm_group_delete').show();
1846 $('#rm_group_scenario').show();
1847 $('#group-metadata-controls .chat_lorebook_button').removeClass('disabled').prop('disabled', false);
1848 $('#group_open_media_overrides').show();
1849 const isMediaAllowed = isExternalMediaAllowed();
1850 $('#group_media_allowed_icon').toggle(isMediaAllowed);
1851 $('#group_media_forbidden_icon').toggle(!isMediaAllowed);
1852 } else {
1853 $('#rm_group_submit').show();
1854 if ($('#groupAddMemberListToggle .inline-drawer-content').css('display') !== 'block') {
1855 $('#groupAddMemberListToggle').trigger('click');
1856 }
1857 $('#rm_group_delete').hide();
1858 $('#rm_group_scenario').hide();
1859 $('#group-metadata-controls .chat_lorebook_button').addClass('disabled').prop('disabled', true);
1860 $('#group_open_media_overrides').hide();
1861 }
1862
1863 updateFavButtonState(group?.fav ?? false);
1864 setAutoModeWorker();
1865
1866 // top bar
1867 if (group) {
1868 $('#rm_group_automode_label').show();
1869 $('#rm_button_selected_ch').children('h2').text(groupName);
1870 } else {
1871 $('#rm_group_automode_label').hide();
1872 }
1873
1874 // Toggle textbox sizes, as input events have not fired here
1875 if (!CSS.supports('field-sizing', 'content')) {
1876 $('#rm_group_chats_block .autoSetHeight').each(element => {
1877 resetScrollHeight(element);
1878 });
1879 }
1880
1881 hideMutedSprites = group?.hideMutedSprites ?? false;
1882 $('#rm_group_hidemutedsprites').prop('checked', hideMutedSprites);
1883
1884 eventSource.emit('groupSelected', { detail: { id: openGroupId, group: group } });
1885}
1886
1887/**
1888 * Handles the upload and processing of a group avatar.
1889 * The selected image is read, cropped using a popup, processed into a thumbnail,
1890 * and then uploaded to the server.
1891 *
1892 * @param {Event} event - The event triggered by selecting a file input, containing the image file to upload.
1893 *
1894 * @returns {Promise<void>} - A promise that resolves when the processing and upload is complete.
1895 */
1896async function uploadGroupAvatar(event) {
1897 if (!(event.target instanceof HTMLInputElement) || !event.target.files.length) {
1898 return;
1899 }
1900
1901 const file = event.target.files[0];
1902
1903 if (!file) {
1904 return;
1905 }
1906
1907 const result = await getBase64Async(file);
1908
1909 $('#dialogue_popup').addClass('large_dialogue_popup wide_dialogue_popup');
1910
1911 const croppedImage = await callGenericPopup('Set the crop position of the avatar image', POPUP_TYPE.CROP, '', { cropImage: result });
1912
1913 if (!croppedImage) {
1914 return;
1915 }
1916
1917 let thumbnail = await createThumbnail(String(croppedImage), 200, 300);
1918 //remove data:image/whatever;base64
1919 thumbnail = thumbnail.replace(/^data:image\/[a-z]+;base64,/, '');
1920 let _thisGroup = groups.find((x) => x.id == openGroupId);
1921 // filename should be group id + human readable timestamp
1922 const filename = _thisGroup ? `${_thisGroup.id}_${humanizedDateTime()}` : humanizedDateTime();
1923 let thumbnailUrl = await saveBase64AsFile(thumbnail, String(openGroupId ?? ''), filename, 'jpg');
1924 if (!openGroupId) {
1925 $('#group_avatar_preview img').attr('src', thumbnailUrl);
1926 $('#rm_group_restore_avatar').show();
1927 return;
1928 }
1929
1930 _thisGroup.avatar_url = thumbnailUrl;
1931 $('#group_avatar_preview').empty().append(getGroupAvatar(_thisGroup));
1932 $('#rm_group_restore_avatar').show();
1933 await editGroup(openGroupId, true, true);
1934}
1935
1936async function restoreGroupAvatar() {
1937 const confirm = await Popup.show.confirm('Are you sure you want to restore the group avatar?', 'Your custom image will be deleted, and a collage will be used instead.');
1938 if (!confirm) {
1939 return;
1940 }
1941
1942 if (!openGroupId) {
1943 $('#group_avatar_preview img').attr('src', default_avatar);
1944 $('#rm_group_restore_avatar').hide();
1945 return;
1946 }
1947
1948 let _thisGroup = groups.find((x) => x.id == openGroupId);
1949 _thisGroup.avatar_url = '';
1950 $('#group_avatar_preview').empty().append(getGroupAvatar(_thisGroup));
1951 $('#rm_group_restore_avatar').hide();
1952 await editGroup(openGroupId, true, true);
1953}
1954
1955async function onGroupActionClick(event) {
1956 event.stopPropagation();
1957 const action = $(this).data('action');
1958 const member = $(this).closest('.group_member');
1959
1960 if (action === 'remove') {
1961 await modifyGroupMember(openGroupId, member, true);
1962 }
1963
1964 if (action === 'add') {
1965 await modifyGroupMember(openGroupId, member, false);
1966 }
1967
1968 if (action === 'enable') {
1969 member.removeClass('disabled');
1970 const _thisGroup = groups.find(x => x.id === openGroupId);
1971 const index = _thisGroup.disabled_members.indexOf(member.data('id'));
1972 if (index !== -1) {
1973 _thisGroup.disabled_members.splice(index, 1);
1974 await editGroup(openGroupId, false, false);
1975 }
1976 }
1977
1978 if (action === 'disable') {
1979 member.addClass('disabled');
1980 const _thisGroup = groups.find(x => x.id === openGroupId);
1981 if (!_thisGroup.disabled_members.includes(member.data('id'))) {
1982 _thisGroup.disabled_members.push(member.data('id'));
1983 await editGroup(openGroupId, false, false);
1984 }
1985 }
1986
1987 if (action === 'up' || action === 'down') {
1988 await reorderGroupMember(openGroupId, member, action);
1989 }
1990
1991 if (action === 'view') {
1992 await openCharacterDefinition(member);
1993 }
1994
1995 if (action === 'speak') {
1996 const chid = Number(member.attr('data-chid'));
1997 if (Number.isInteger(chid)) {
1998 Generate('normal', { force_chid: chid });
1999 }
2000 }
2001
2002 await eventSource.emit(event_types.GROUP_UPDATED);
2003}
2004
2005function updateFavButtonState(state) {
2006 fav_grp_checked = state;
2007 $('#rm_group_fav').val(String(fav_grp_checked));
2008 $('#group_favorite_button').toggleClass('fav_on', fav_grp_checked);
2009 $('#group_favorite_button').toggleClass('fav_off', !fav_grp_checked);
2010}
2011
2012/**
2013 * Opens a group chat by its ID and updates the UI accordingly.
2014 * @param {string} groupId ID of the group to open
2015 * @returns {Promise<boolean>} Whether the group was opened
2016 */
2017export async function openGroupById(groupId) {
2018 if (isChatSaving) {
2019 toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`);
2020 return false;
2021 }
2022
2023 if (!groups.find(x => x.id === groupId)) {
2024 console.log('Group not found', groupId);
2025 return false;
2026 }
2027
2028 if (!is_send_press && !is_group_generating) {
2029 select_group_chats(groupId, false);
2030
2031 if (selected_group !== groupId) {
2032 groupChatQueueOrder = new Map();
2033 setCharacterId(undefined);
2034 setCharacterName('');
2035 resetSelectedGroup();
2036 await clearChat({ clearData: true });
2037 cancelTtsPlay();
2038 selected_group = groupId;
2039 setEditedMessageId(undefined);
2040 updateChatMetadata({}, true);
2041 await getGroupChat(groupId);
2042 return true;
2043 }
2044 }
2045
2046 return false;
2047}
2048
2049/**
2050 * Peeks the character definition from a group member element.
2051 * @param {JQuery<HTMLElement>} characterSelect Character select element
2052 * @returns {Promise<void>}
2053 */
2054async function openCharacterDefinition(characterSelect) {
2055 if (is_group_generating) {
2056 toastr.warning(t`Can't peek a character while group reply is being generated`);
2057 console.warn('Can\'t peek a character def while group reply is being generated');
2058 return;
2059 }
2060
2061 const chid = characterSelect.attr('data-chid');
2062
2063 if (chid === null || chid === undefined) {
2064 return;
2065 }
2066
2067 await unshallowCharacter(chid);
2068 setCharacterId(chid);
2069 select_selected_character(chid);
2070 // Gentle nudge to recalculate tokens
2071 RA_CountCharTokens();
2072 // Do a little tomfoolery to spoof the tag selector
2073 applyTagsOnCharacterSelect.call(characterSelect);
2074}
2075
2076function filterGroupMembers() {
2077 const searchValue = String($(this).val()).toLowerCase();
2078 groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
2079}
2080
2081function filterGroupMemberList() {
2082 const searchValue = String($(this).val()).toLowerCase();
2083 groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
2084}
2085
2086async function createGroup() {
2087 let name = $('#rm_group_chat_name').val().toString();
2088 let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');
2089 let activationStrategy = Number($('#rm_group_activation_strategy').find(':selected').val()) ?? group_activation_strategy.NATURAL;
2090 let generationMode = Number($('#rm_group_generation_mode').find(':selected').val()) ?? group_generation_mode.SWAP;
2091 let autoModeDelay = Number($('#rm_group_automode_delay').val()) ?? DEFAULT_AUTO_MODE_DELAY;
2092 const members = newGroupMembers;
2093 const memberNames = characters.filter(x => members.includes(x.avatar)).map(x => x.name).join(', ');
2094
2095 if (!name) {
2096 name = t`Group: ${memberNames}`;
2097 }
2098
2099 const avatarUrl = $('#group_avatar_preview img').attr('src');
2100 const chatName = humanizedDateTime();
2101 const chats = [chatName];
2102
2103 /** @type {Omit<Group, 'id'>} */
2104 const groupCreateModel = {
2105 name: name,
2106 members: members,
2107 avatar_url: isValidImageUrl(avatarUrl) ? avatarUrl : default_avatar,
2108 allow_self_responses: allowSelfResponses,
2109 hideMutedSprites: hideMutedSprites,
2110 activation_strategy: activationStrategy,
2111 generation_mode: generationMode,
2112 disabled_members: [],
2113 fav: fav_grp_checked,
2114 chat_id: chatName,
2115 chats: chats,
2116 auto_mode_delay: autoModeDelay,
2117 };
2118
2119 const createGroupResponse = await fetch('/api/groups/create', {
2120 method: 'POST',
2121 headers: getRequestHeaders(),
2122 body: JSON.stringify(groupCreateModel),
2123 });
2124
2125 if (createGroupResponse.ok) {
2126 newGroupMembers = [];
2127 const data = await createGroupResponse.json();
2128 createTagMapFromList('#groupTagList', data.id);
2129 await getCharacters();
2130 select_rm_info('group_create', data.id);
2131 }
2132}
2133
2134/**
2135 * Creates a new group chat within the specified group.
2136 * @param {string} groupId Group ID
2137 * @returns {Promise<void>} Promise that resolves when the new group chat is created
2138 */
2139export async function createNewGroupChat(groupId) {
2140 const group = groups.find(x => x.id === groupId);
2141
2142 if (!group) {
2143 return;
2144 }
2145
2146 await clearChat({ clearData: true });
2147 const newChatName = humanizedDateTime();
2148 group.chats.push(newChatName);
2149 group.chat_id = newChatName;
2150 updateChatMetadata({}, true);
2151
2152 await editGroup(group.id, true, false);
2153 await getGroupChat(group.id);
2154}
2155
2156/**
2157 * Retrieves past chats for a specified group.
2158 * @param {string} groupId Group ID
2159 * @returns {Promise<Array<import('../../src/endpoints/chats.js').ChatInfo>>} Array of past chats
2160 */
2161export async function getGroupPastChats(groupId) {
2162 const group = groups.find(x => x.id === groupId);
2163
2164 if (!group) {
2165 return [];
2166 }
2167
2168 const chats = [];
2169
2170 try {
2171 for (const chatId of group.chats) {
2172 const response = await fetch('/api/chats/group/info', {
2173 method: 'POST',
2174 headers: getRequestHeaders(),
2175 body: JSON.stringify({ id: chatId }),
2176 });
2177 if (response.ok) {
2178 const data = await response.json();
2179 chats.push(data);
2180 }
2181 }
2182 } catch (err) {
2183 console.error(err);
2184 }
2185 return chats;
2186}
2187
2188/**
2189 * Opens a specific group chat for the specified group by its ID.
2190 * @param {string} groupId Group ID
2191 * @param {string} chatId Chat ID
2192 * @returns {Promise<void>}
2193 */
2194export async function openGroupChat(groupId, chatId) {
2195 await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
2196 const group = groups.find(x => x.id === groupId);
2197
2198 if (!group || !group.chats.includes(chatId)) {
2199 return;
2200 }
2201
2202 await clearChat({ clearData: true });
2203 group.chat_id = chatId;
2204 group.date_last_chat = Date.now();
2205 updateChatMetadata({}, true);
2206
2207 await editGroup(groupId, true, false);
2208 await getGroupChat(groupId);
2209}
2210
2211/**
2212 * Renames a group chat within the specified group.
2213 * @param {string} groupId Group ID
2214 * @param {string} oldChatId Old chat ID
2215 * @param {string} newChatId New chat ID
2216 * @returns {Promise<void>} Promise that resolves when the group chat is renamed
2217 */
2218export async function renameGroupChat(groupId, oldChatId, newChatId) {
2219 const group = groups.find(x => x.id === groupId);
2220
2221 if (!group || !group.chats.includes(oldChatId)) {
2222 return;
2223 }
2224
2225 if (group.chat_id === oldChatId) {
2226 group.chat_id = newChatId;
2227 }
2228
2229 group.chats.splice(group.chats.indexOf(oldChatId), 1);
2230 group.chats.push(newChatId);
2231
2232 await editGroup(groupId, true, true);
2233}
2234
2235/**
2236 * Deletes a group chat by its name. Doesn't affect displayed chat.
2237 * @param {string} groupId Group ID
2238 * @param {string} chatName Name of the chat to delete
2239 * @returns {Promise<void>}
2240 */
2241export async function deleteGroupChatByName(groupId, chatName) {
2242 const group = groups.find(x => x.id === groupId);
2243 if (!group || !group.chats.includes(chatName)) {
2244 return;
2245 }
2246
2247 group.chats.splice(group.chats.indexOf(chatName), 1);
2248
2249 const response = await fetch('/api/chats/group/delete', {
2250 method: 'POST',
2251 headers: getRequestHeaders(),
2252 body: JSON.stringify({ id: chatName }),
2253 });
2254
2255 if (!response.ok) {
2256 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group chat could not be deleted`);
2257 console.error('Group chat could not be deleted');
2258 return;
2259 }
2260
2261 // If the deleted chat was the current chat, switch to the last chat in the group
2262 if (group.chat_id === chatName) {
2263 const newChatName = group.chats.length ? group.chats[group.chats.length - 1] : humanizedDateTime();
2264 group.chat_id = newChatName;
2265 }
2266
2267 await editGroup(groupId, true, true);
2268 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatName);
2269}
2270
2271/**
2272 * Deletes a group chat by name.
2273 * @param {string} groupId The ID of the group containing the chat to delete.
2274 * @param {string} chatId The id/name of the chat to delete.
2275 * @param {object} [options={}] Options for the deletion.
2276 * @param {boolean} [options.jumpToNewChat=true] Whether to jump to a new chat after deletion (existing one, or create a new one if none exists)
2277 */
2278export async function deleteGroupChat(groupId, chatId, { jumpToNewChat = true } = {}) {
2279 const group = groups.find(x => x.id === groupId);
2280
2281 if (!group || !group.chats.includes(chatId)) {
2282 return;
2283 }
2284
2285 group.chats.splice(group.chats.indexOf(chatId), 1);
2286
2287 if (group.chat_id === chatId) {
2288 group.chat_id = '';
2289 updateChatMetadata({}, true);
2290 }
2291
2292 const response = await fetch('/api/chats/group/delete', {
2293 method: 'POST',
2294 headers: getRequestHeaders(),
2295 body: JSON.stringify({ id: chatId }),
2296 });
2297
2298 if (response.ok) {
2299 if (jumpToNewChat) {
2300 if (group.chats.length) {
2301 await openGroupChat(groupId, group.chats[group.chats.length - 1]);
2302 } else {
2303 await createNewGroupChat(groupId);
2304 }
2305 }
2306
2307 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatId);
2308 }
2309}
2310
2311/**
2312 * Imports a group chat from a file and adds it to the group.
2313 * @param {FormData} formData Form data to send to the server
2314 * @param {object} [options={}] Options for the import
2315 * @param {boolean} [options.refresh] Whether to refresh the group chat list after import
2316 * @returns {Promise<string[]>} List of imported file names
2317 */
2318export async function importGroupChat(formData, { refresh = true } = {}) {
2319 const fetchResult = await fetch('/api/chats/group/import', {
2320 method: 'POST',
2321 headers: getRequestHeaders({ omitContentType: true }),
2322 body: formData,
2323 cache: 'no-cache',
2324 });
2325
2326 if (fetchResult.ok) {
2327 const data = await fetchResult.json();
2328 if (data.res) {
2329 const chatId = data.res;
2330 const group = groups.find(x => x.id == selected_group);
2331
2332 if (group) {
2333 group.chats.push(chatId);
2334 await editGroup(selected_group, true, true);
2335 if (refresh) {
2336 await displayPastChats();
2337 }
2338 }
2339
2340 return [data.res];
2341 }
2342
2343 return data?.fileNames || [];
2344 }
2345
2346 return [];
2347}
2348
2349/**
2350 * Saves the current group chat as a bookmark chat.
2351 * @param {string} groupId Group ID
2352 * @param {string} name Name of the chat to save
2353 * @param {ChatMetadata?} metadata New metadata to save with the chat
2354 * @param {number|undefined} mesId Optional message ID to trim the chat up to
2355 * @param {ChatMessage[]|undefined} chatData Optional chat snapshot to save instead of the current in-memory chat
2356 * @returns {Promise<void>} Promise that resolves when the group chat is saved
2357 */
2358export async function saveGroupBookmarkChat(groupId, name, metadata, mesId, chatData = undefined) {
2359 const group = groups.find(x => x.id === groupId);
2360
2361 if (!group) {
2362 return;
2363 }
2364
2365 group.chats.push(name);
2366
2367 /** @type {ChatHeader} */
2368 const chatHeader = {
2369 chat_metadata: { ...chat_metadata, ...(metadata || {}) },
2370 user_name: 'unused',
2371 character_name: 'unused',
2372 };
2373
2374 /** @type {ChatMessage[]} */
2375 const trimmedChat = Array.isArray(chatData)
2376 ? chatData
2377 : (mesId !== undefined && mesId >= 0 && mesId < chat.length)
2378 ? chat.slice(0, Number(mesId) + 1)
2379 : chat;
2380
2381 await editGroup(groupId, true, false);
2382
2383 const saveChatRequest = await compressRequest({
2384 method: 'POST',
2385 headers: getRequestHeaders(),
2386 body: JSON.stringify({ id: name, chat: [chatHeader, ...trimmedChat] }),
2387 });
2388 const response = await fetch('/api/chats/group/save', saveChatRequest);
2389
2390 if (!response.ok) {
2391 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group chat could not be saved`);
2392 console.error('Group chat could not be saved', response);
2393 }
2394}
2395
2396function onSendTextareaInput() {
2397 if (is_group_automode_enabled) {
2398 // Wait for current automode generation to finish
2399 is_group_automode_enabled = false;
2400 $('#rm_group_automode').prop('checked', false);
2401 }
2402}
2403
2404function stopAutoModeGeneration() {
2405 if (groupAutoModeAbortController) {
2406 groupAutoModeAbortController.abort();
2407 }
2408
2409 is_group_automode_enabled = false;
2410 $('#rm_group_automode').prop('checked', false);
2411}
2412
2413function doCurMemberListPopout() {
2414 //repurposes the zoomed avatar template to server as a floating group member list
2415 if ($('#groupMemberListPopout').length === 0) {
2416 console.debug('did not see popout yet, creating');
2417 const memberListClone = $(this).parent().parent().find('.inline-drawer-content').html();
2418 const template = $('#zoomed_avatar_template').html();
2419 const controlBarHtml = `<div class="panelControlBar flex-container">
2420 <div id="groupMemberListPopoutheader" class="fa-solid fa-grip drag-grabber hoverglow"></div>
2421 <div id="groupMemberListPopoutClose" class="fa-solid fa-circle-xmark hoverglow"></div>
2422 </div>`;
2423 const newElement = $(template);
2424
2425 newElement.attr('id', 'groupMemberListPopout')
2426 .removeClass('zoomed_avatar')
2427 .addClass('draggable')
2428 .empty()
2429 .append(controlBarHtml)
2430 .append(memberListClone);
2431
2432 // Remove pagination from popout
2433 newElement.find('.group_pagination').empty();
2434
2435 $('#movingDivs').append(newElement);
2436 loadMovingUIState();
2437 $('#groupMemberListPopout').fadeIn(animation_duration);
2438 dragElement(newElement);
2439 $('#groupMemberListPopoutClose').off('click').on('click', function () {
2440 $('#groupMemberListPopout').fadeOut(animation_duration, () => { $('#groupMemberListPopout').remove(); });
2441 });
2442
2443 // Re-add pagination not working in popout
2444 printGroupMembers();
2445 } else {
2446 console.debug('saw existing popout, removing');
2447 $('#groupMemberListPopout').fadeOut(animation_duration, () => { $('#groupMemberListPopout').remove(); });
2448 }
2449}
2450
2451jQuery(() => {
2452 if (!CSS.supports('field-sizing', 'content')) {
2453 $(document).on('input', '#rm_group_chats_block .autoSetHeight', function () {
2454 resetScrollHeight($(this));
2455 });
2456 }
2457
2458 $(document).on('click', '.group_select', function () {
2459 const groupId = $(this).attr('data-chid') || $(this).attr('data-grid');
2460 openGroupById(groupId);
2461 });
2462 $('#rm_group_filter').on('input', filterGroupMembers);
2463 $('#rm_group_members_filter').on('input', filterGroupMemberList);
2464 $('#rm_group_submit').on('click', createGroup);
2465 $('#rm_group_scenario').on('click', setCharacterSettingsOverrides);
2466 $('#rm_group_automode').on('input', function () {
2467 const value = $(this).prop('checked');
2468 is_group_automode_enabled = value;
2469 eventSource.once(event_types.GENERATION_STOPPED, stopAutoModeGeneration);
2470 });
2471 $('#rm_group_hidemutedsprites').on('input', function () {
2472 const value = $(this).prop('checked');
2473 hideMutedSprites = value;
2474 onHideMutedSpritesClick(value);
2475 });
2476 $('#send_textarea').on('keyup', onSendTextareaInput);
2477 $('#groupCurrentMemberPopoutButton').on('click', doCurMemberListPopout);
2478 $('#rm_group_chat_name').on('input', onGroupNameInput);
2479 $('#rm_group_delete').off().on('click', onDeleteGroupClick);
2480 $('#group_favorite_button').on('click', onFavoriteGroupClick);
2481 $('#rm_group_allow_self_responses').on('input', onGroupSelfResponsesClick);
2482 $('#rm_group_activation_strategy').on('change', onGroupActivationStrategyInput);
2483 $('#rm_group_generation_mode').on('change', onGroupGenerationModeInput);
2484 $('#rm_group_automode_delay').on('input', onGroupAutoModeDelayInput);
2485 $('#rm_group_generation_mode_join_prefix').on('input', onGroupGenerationModeTemplateInput);
2486 $('#rm_group_generation_mode_join_suffix').on('input', onGroupGenerationModeTemplateInput);
2487 $('#group_avatar_button').on('input', uploadGroupAvatar);
2488 $('#rm_group_restore_avatar').on('click', restoreGroupAvatar);
2489 $(document).on('click', '.group_member .right_menu_button', onGroupActionClick);
2490});