| 1 | import { Fuse } from '../lib.js'; |
| 2 | |
| 3 | import { |
| 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'; |
| 22 | import { RA_CountCharTokens, humanizedDateTime, dragElement, favsToHotswap, getMessageTimeStamp } from './RossAscends-mods.js'; |
| 23 | import { power_user, loadMovingUIState, sortEntitiesList } from './power-user.js'; |
| 24 | import { debounce_timeout } from './constants.js'; |
| 25 | |
| 26 | import { |
| 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'; |
| 83 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js'; |
| 84 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| 85 | import { isExternalMediaAllowed } from './chats.js'; |
| 86 | import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 87 | import { t } from './i18n.js'; |
| 88 | import { accountStorage } from './util/AccountStorage.js'; |
| 89 | import { compressRequest } from './request-compression.js'; |
| 90 | |
| 91 | export { |
| 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 | |
| 110 | let is_group_generating = false; // Group generation flag |
| 111 | let is_group_automode_enabled = false; |
| 112 | let hideMutedSprites = false; |
| 113 | /** @type {Group[]} */ |
| 114 | let groups = []; |
| 115 | /** @type {string|null} */ |
| 116 | let selected_group = null; |
| 117 | let group_generation_id = null; |
| 118 | let fav_grp_checked = false; |
| 119 | let openGroupId = null; |
| 120 | let newGroupMembers = []; |
| 121 | |
| 122 | export const group_activation_strategy = { |
| 123 | NATURAL: 0, |
| 124 | LIST: 1, |
| 125 | MANUAL: 2, |
| 126 | POOLED: 3, |
| 127 | }; |
| 128 | |
| 129 | export const group_generation_mode = { |
| 130 | SWAP: 0, |
| 131 | APPEND: 1, |
| 132 | APPEND_DISABLED: 2, |
| 133 | }; |
| 134 | |
| 135 | export const DEFAULT_AUTO_MODE_DELAY = 5; |
| 136 | |
| 137 | export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick)); |
| 138 | export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick)); |
| 139 | let autoModeWorker = null; |
| 140 | const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed); |
| 141 | /** @type {Map<string, number>} */ |
| 142 | let groupChatQueueOrder = new Map(); |
| 143 | |
| 144 | function 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 | */ |
| 155 | async 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 |
| 167 | async 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 | */ |
| 195 | async 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 | */ |
| 218 | async 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 | */ |
| 255 | export 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 | */ |
| 328 | export 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 | */ |
| 337 | export 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 | */ |
| 353 | export 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 | */ |
| 427 | export 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 | */ |
| 477 | export 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 | */ |
| 497 | export 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 | */ |
| 578 | async 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 | |
| 611 | function 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 | */ |
| 623 | async 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 | */ |
| 683 | export 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 | */ |
| 758 | async 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 | */ |
| 800 | export 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 | */ |
| 841 | function 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 | */ |
| 858 | function 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 | */ |
| 871 | function 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 | */ |
| 924 | function 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 | */ |
| 945 | async 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 | */ |
| 1098 | function 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 | */ |
| 1114 | function 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 | */ |
| 1130 | function 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 | */ |
| 1180 | function 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 | */ |
| 1197 | function 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 | */ |
| 1242 | function 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 | */ |
| 1323 | async 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 | */ |
| 1359 | export 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 | */ |
| 1378 | export 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 | |
| 1396 | let groupAutoModeAbortController = null; |
| 1397 | |
| 1398 | async 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 | */ |
| 1423 | async 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 | */ |
| 1461 | async 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 | |
| 1491 | async 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 | |
| 1499 | async 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 | |
| 1509 | async 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 | |
| 1518 | async 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 | |
| 1527 | async 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 | */ |
| 1542 | function 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 | */ |
| 1557 | function 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 | |
| 1617 | function 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 | |
| 1647 | function 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 | */ |
| 1685 | function 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 | */ |
| 1731 | function isGroupMemberDisabled(avatarId) { |
| 1732 | const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId); |
| 1733 | return Boolean(thisGroup && thisGroup.disabled_members.includes(avatarId)); |
| 1734 | } |
| 1735 | |
| 1736 | async 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 | |
| 1752 | async 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 | |
| 1762 | async 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 | |
| 1771 | async 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 | */ |
| 1786 | function 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 | */ |
| 1802 | function 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 | */ |
| 1896 | async 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 | |
| 1936 | async 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 | |
| 1955 | async 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 | |
| 2005 | function 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 | */ |
| 2017 | export 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 | */ |
| 2054 | async 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 | |
| 2076 | function filterGroupMembers() { |
| 2077 | const searchValue = String($(this).val()).toLowerCase(); |
| 2078 | groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue); |
| 2079 | } |
| 2080 | |
| 2081 | function filterGroupMemberList() { |
| 2082 | const searchValue = String($(this).val()).toLowerCase(); |
| 2083 | groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue); |
| 2084 | } |
| 2085 | |
| 2086 | async 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 | */ |
| 2139 | export 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 | */ |
| 2161 | export 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 | */ |
| 2194 | export 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 | */ |
| 2218 | export 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 | */ |
| 2241 | export 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 | */ |
| 2278 | export 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 | */ |
| 2318 | export 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 | */ |
| 2358 | export 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 | |
| 2396 | function 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 | |
| 2404 | function stopAutoModeGeneration() { |
| 2405 | if (groupAutoModeAbortController) { |
| 2406 | groupAutoModeAbortController.abort(); |
| 2407 | } |
| 2408 | |
| 2409 | is_group_automode_enabled = false; |
| 2410 | $('#rm_group_automode').prop('checked', false); |
| 2411 | } |
| 2412 | |
| 2413 | function 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 | |
| 2451 | jQuery(() => { |
| 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 | }); |