Update group chat metadata format (#4805) * Migrate group metadata to group chat files * Skip migration if chat already has metadata * Fix active group not being set on group conversion * Improve types in createGroup * Fix padding in hotswap group avatars * Fix centering of empty hotswap avatar * Added automatic backups of migrated data * Fix 'OVERWRITE' for GC * Fix metadata parsing order in migration * Remove color accents from regular migration logs * Always set gen_id in converted message * Clone messages before conversion * Reduce size of add/remove buttons * Fix group chat file size calculation

929d377da8cf3f08343d6ec95fa1a43c7053fb92

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

Signed
17 files changed, +564 -190Ignore whitespace
public/css/rm-groups.css+8 -0
@@ -212,6 +212,14 @@
212212 background-color: var(--white30a);
213213}
214214
215+.group_select.avatar {
216+ padding: 0;
217+}
218+
219+.group_select.missing-avatar.inline_avatar {
220+ justify-content: center;
221+}
222+
215223.group_select .avatar {
216224 flex: 0;
217225}
public/global.d.ts+37 -0
@@ -15,6 +15,42 @@ declare global {
1515 type ChatCompletionSettings = typeof oai_settings;
1616 type TextCompletionSettings = typeof textgenerationwebui_settings;
1717 type MessageTimestamp = string | number | Date;
18+ type Character = import('./scripts/char-data').v1CharData;
19+
20+ interface Group {
21+ id: string;
22+ name: string;
23+ members: string[];
24+ disabled_members: string[];
25+ chat_id: string;
26+ chats: string[];
27+ generation_mode?: number;
28+ generation_mode_join_prefix?: string;
29+ generation_mode_join_suffix?: string;
30+ activation_strategy?: number;
31+ auto_mode_delay?: number;
32+ allow_self_responses?: boolean;
33+ avatar_url?: string;
34+ hideMutedSprites?: boolean;
35+ fav?: boolean;
36+ }
37+
38+ interface ChatFile extends Array<ChatMessage> {
39+ [index: number]: ChatMessage;
40+ 0?: ChatHeader;
41+ }
42+
43+ interface ChatHeader {
44+ chat_metadata: ChatMetadata;
45+ }
46+
47+ interface ChatMetadata {
48+ tainted?: boolean;
49+ integrity?: string;
50+ scenario?: string;
51+ persona?: string;
52+ [key: string]: any;
53+ }
1854
1955 interface ChatMessage {
2056 name?: string;
@@ -34,6 +70,7 @@ declare global {
3470 };
3571
3672 interface ChatMessageExtra {
73+ gen_id?: number;
3774 bias?: string;
3875 uses_system_ui?: boolean;
3976 memory?: string;
public/index.html+2 -3
@@ -7215,9 +7215,8 @@
72157215 <div title="Move down" data-action="down" class="right_menu_button fa-solid fa-chevron-down" data-i18n="[title]Move down"></div>
72167216 </div>
72177217 <div title="View character card" data-action="view" class="right_menu_button fa-solid fa-xl fa-image-portrait" data-i18n="[title]View character card"></div>
72187218 <div title="Remove from group" data-action="remove" class="right_menu_button fa-solid fa-2xlxl fa-xmark" data-i18n="[title]Remove from group"></div>
7219- </div>
7219+ <div title="Add to group" data-action="add" class="right_menu_button fa-solid fa-xl fa-plus" data-i18n="[title]Add to group"></div>
7220- <div title="Add to group" data-action="add" class="right_menu_button fa-solid fa-2xl fa-plus" data-i18n="[title]Add to group"></div>
72217220 </div>
72227221 </div>
72237222 </div>
public/script.js+18 -33
@@ -65,7 +65,6 @@ import {
6565 renameGroupMember,
6666 createNewGroupChat,
6767 getGroupAvatar,
68- editGroup,
6968 deleteGroupChat,
7069 renameGroupChat,
7170 importGroupChat,
@@ -377,14 +376,13 @@ export let isSwipingAllowed = true; //false when a swipe is in progress, or swip
377376let chatSaveTimeout;
378377let importFlashTimeout;
379378export let isChatSaving = false;
380-let chat_create_date = '';
381379let firstRun = false;
382380let settingsReady = false;
383381let currentVersion = '0.0.0';
384382export let displayVersion = 'SillyTavern';
385383
386384let generation_started = new Date();
387385/** @type {import('./scripts/char-data.js').v1CharDataCharacter[]} */
388386export let characters = [];
389387/**
390388 * Stringified index of a currently chosen entity in the characters array.
@@ -411,6 +409,7 @@ export const chatElement = $('#chat');
411409
412410let dialogueResolve = null;
413411let dialogueCloseStop = false;
412+/** @type {ChatMetadata} */
414413export let chat_metadata = {};
415414/** @type {StreamingProcessor} */
416415export let streamingProcessor = null;
@@ -1295,7 +1294,7 @@ export async function deleteCharacterChatByName(characterId, fileName) {
12951294 // Make sure all the data is loaded.
12961295 await unshallowCharacter(characterId);
12971296
12981297 /** @type {import('./scripts/char-data.js').v1CharDataCharacter} */
12991298 const character = characters[characterId];
13001299 if (!character) {
13011300 console.warn(`Character with ID ${characterId} not found.`);
@@ -6731,7 +6730,7 @@ export async function renameCharacter(name = null, { silent = false, renameChats
67316730 }
67326731
67336732 // Also rename as a group member
67346733 await renameGroupMember(oldAvatar, newAvatar, newValue.toString());
67356734 const renamePastChatsConfirm = renameChats !== null
67366735 ? renameChats
67376736 : silent
@@ -6860,6 +6859,11 @@ export function saveChatDebounced() {
68606859 * @returns {Promise<void>}
68616860 */
68626861export async function saveChat({ chatName, withMetadata, mesId, force = false } = {}) {
6862+ if (selected_group) {
6863+ toastr.error(t`Operation was aborted to prevent data corruption.`, t`saveChat called for a group chat`);
6864+ throw new Error('saveChat called for a group chat');
6865+ }
6866+
68636867 if (arguments.length > 0 && typeof arguments[0] !== 'object') {
68646868 console.trace('saveChat called with positional arguments. Please use an object instead.');
68656869 [chatName, withMetadata, mesId, force] = arguments;
@@ -6879,26 +6883,15 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
68796883 }
68806884
68816885 characters[this_chid]['date_last_chat'] = Date.now();
6882- chat.forEach(function (item, i) {
6883- if (item['is_group']) {
6884- toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`);
6885- throw new Error('Group chat saved from saveChat');
6886- }
6887- });
68886886
68896887 const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
68906888 ? chat.slice(0, Number(mesId) + 1)
68916889 : chat.slice();
68926890
6893- const chatToSave = [
6891+ /** @type {ChatHeader} */
6894- {
6892+ const chatHeader = {
68956893 user_name chat_metadata: name1metadata,
6896- character_name: name2,
6894+ };
6897- create_date: chat_create_date,
6898- chat_metadata: metadata,
6899- },
6900- ...trimmedChat,
6901- ];
69026895
69036896 try {
69046897 const result = await fetch('/api/chats/save', {
@@ -6908,7 +6901,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
69086901 body: JSON.stringify({
69096902 ch_name: characters[this_chid].name,
69106903 file_name: fileName,
69116904 chat: chatToSave[chatHeader, ...trimmedChat],
69126905 avatar_url: characters[this_chid].avatar,
69136906 force: force,
69146907 }),
@@ -7082,7 +7075,7 @@ export async function unshallowCharacter(characterId) {
70827075 return;
70837076 }
70847077
70857078 /** @type {import('./scripts/char-data.js').v1CharDataCharacter} */
70867079 const character = characters[characterId];
70877080 if (!character) {
70887081 console.debug('Character not found:', characterId);
@@ -7121,13 +7114,10 @@ export async function getChat() {
71217114 });
71227115 if (response[0] !== undefined) {
71237116 chat.splice(0, chat.length, ...response);
7124- chat_create_date = chat[0]['create_date'];
71257117 chat_metadata = chat[0]['chat_metadata'] ?? {};
71267118
71277119 chat.shift();
71287120 chat.forEach(ensureMessageMediaIsArray);
7129- } else {
7130- chat_create_date = humanizedDateTime();
71317121 }
71327122 if (!chat_metadata['integrity']) {
71337123 chat_metadata['integrity'] = uuidv4();
@@ -8720,12 +8710,7 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
87208710}
87218711
87228712export async function saveMetadata() {
8723- if (selected_group) {
8713+ return await saveChatConditional();
8724- await editGroup(selected_group, true, false);
8725- }
8726- else {
8727- await saveChatConditional();
8728- }
87298714}
87308715
87318716export async function saveChatConditional() {
@@ -10356,7 +10341,7 @@ jQuery(async function () {
1035610341 });
1035710342 $('#rm_button_selected_ch').on('click', function () {
1035810343 if (selected_group) {
1035910344 select_group_chats(selected_group, false);
1036010345 } else {
1036110346 selected_button = 'character_edit';
1036210347 select_selected_character(this_chid);
@@ -11289,7 +11274,7 @@ jQuery(async function () {
1128911274
1129011275 $('#rm_button_group_chats').on('click', function () {
1129111276 selected_button = 'group_chats';
1129211277 select_group_chats(null, false);
1129311278 });
1129411279
1129511280 $('#rm_button_back_from_group').on('click', function () {
public/scripts/bookmarks.js+29 -28
@@ -11,6 +11,7 @@ import {
1111 chat,
1212 saveChatConditional,
1313 saveItemizedPrompts,
14+ setActiveGroup,
1415} from '../script.js';
1516import { humanizedDateTime } from './RossAscends-mods.js';
1617import {
@@ -298,28 +299,31 @@ export async function convertSoloToGroupChat() {
298299 const chats = [chatName];
299300 const members = [character.avatar];
300301 const favChecked = character.fav || character.fav == 'true';
301302 /** @type {anyChatMetadata} */
302303 const metadata = Object.assign({}, chat_metadata);
303304 delete metadata.main_chat;
305+ /** @type {ChatHeader} */
306+ const chatHeader = { chat_metadata: metadata };
307+ /** @type {Omit<Group, 'id'>} */
308+ const groupCreateModel = {
309+ name: name,
310+ members: members,
311+ avatar_url: avatar,
312+ allow_self_responses: false,
313+ activation_strategy: group_activation_strategy.NATURAL,
314+ disabled_members: [],
315+ fav: favChecked,
316+ chat_id: chatName,
317+ chats: chats,
318+ hideMutedSprites: false,
319+ generation_mode: group_generation_mode.SWAP,
320+ auto_mode_delay: DEFAULT_AUTO_MODE_DELAY,
321+ };
304322
305323 const createGroupResponse = await fetch('/api/groups/create', {
306324 method: 'POST',
307325 headers: getRequestHeaders(),
308326 body: JSON.stringify({groupCreateModel),
309- name: name,
310- members: members,
311- avatar_url: avatar,
312- allow_self_responses: false,
313- activation_strategy: group_activation_strategy.NATURAL,
314- disabled_members: [],
315- chat_metadata: metadata,
316- fav: favChecked,
317- chat_id: chatName,
318- chats: chats,
319- hideMutedSprites: false,
320- generation_mode: group_generation_mode.SWAP,
321- auto_mode_delay: DEFAULT_AUTO_MODE_DELAY,
322- }),
323327 });
324328
325329 if (!createGroupResponse.ok) {
@@ -327,6 +331,7 @@ export async function convertSoloToGroupChat() {
327331 return;
328332 }
329333
334+ /** @type {Group} */
330335 const group = await createGroupResponse.json();
331336
332337 // Convert tags list and assign to group
@@ -336,39 +341,34 @@ export async function convertSoloToGroupChat() {
336341 await getCharacters();
337342
338343 // Convert chat to group format
339344 const groupChat = [...chat].slicemap(m => structuredClone(m));
340345 const genIdFirst = Date.now();
341346
342347 for (let index = 0; index < groupChat.length; index++) {
343348 const message = groupChat[index];
344349
345- // Save group-chat marker
346- if (index == 0) {
347- // @ts-ignore
348- message.is_group = true;
349- }
350-
351350 // Skip messages we don't care about
352351 if (message.is_user || message.is_system || message.extra?.type === system_message_types.NARRATOR || message.force_avatar !== undefined) {
353352 continue;
354353 }
355354
355+ if (!message.extra || typeof message.extra !== 'object') {
356+ message.extra = {};
357+ }
358+
356359 // Set force fields for solo character
357360 message.name = character.name;
358361 message.original_avatar = character.avatar;
359362 message.force_avatar = getThumbnailUrl('avatar', character.avatar);
360-
361363 // Allow regens of a single message in group
362- if (typeof message.extra !== 'object') {
364+ message.extra.gen_id = genIdFirst + index;
363- message.extra = { gen_id: genIdFirst + index };
364- }
365365 }
366366
367367 // Save group chat
368368 const createChatResponse = await fetch('/api/chats/group/save', {
369369 method: 'POST',
370370 headers: getRequestHeaders(),
371371 body: JSON.stringify({ id: chatName, chat: [chatHeader, ...groupChat] }),
372372 });
373373
374374 if (!createChatResponse.ok) {
@@ -378,6 +378,7 @@ export async function convertSoloToGroupChat() {
378378 }
379379
380380 // Click on the freshly selected group to open it
381+ setActiveGroup(group.id);
381382 await openGroupById(group.id);
382383
383384 toastr.success(t`The chat has been successfully converted!`);
public/scripts/extensions/attachments/index.js+1 -1
@@ -218,7 +218,7 @@ function cleanUpAttachments() {
218218
219219/**
220220 * Clean up character attachments when a character is deleted.
221221 * @param {{character: import('../../char-data.js').v1CharDataCharacter}} data Event data
222222 */
223223function cleanUpCharacterAttachments(data) {
224224 const avatar = data?.character?.avatar;
public/scripts/extensions/gallery/index.js+1 -1
@@ -95,7 +95,7 @@ function initSettings() {
9595
9696/**
9797 * Retrieves the gallery folder for a given character.
9898 * @param {import('../../char-data.js').v1CharDataCharacter} char Character data
9999 * @returns {string} The gallery folder for the character
100100 */
101101function getGalleryFolder(char) {
public/scripts/extensions/quick-reply/index.js+1 -1
@@ -152,7 +152,7 @@ const handleCharChange = () => {
152152 lastCharId = this_chid;
153153
154154 // If no character is loaded, there's nothing more to do.
155155 /** @type {import('../../char-data.js').v1CharDataCharacter} */
156156 const character = characters[this_chid];
157157 if (!character || selected_group) {
158158 return;
public/scripts/extensions/regex/engine.js+3 -3
@@ -105,7 +105,7 @@ export async function saveScriptsByType(scripts, scriptType) {
105105
106106/**
107107 * Check if character's regexes are allowed to be used; if character is undefined, returns false
108108 * @param {import('../../char-data.js').v1CharDataCharacter|undefined} character
109109 * @returns {boolean}
110110 */
111111export function isScopedScriptsAllowed(character) {
@@ -114,7 +114,7 @@ export function isScopedScriptsAllowed(character) {
114114
115115/**
116116 * Allow character's regexes to be used; if character is undefined, do nothing
117117 * @param {import('../../char-data.js').v1CharDataCharacter|undefined} character
118118 * @returns {void}
119119 */
120120export function allowScopedScripts(character) {
@@ -133,7 +133,7 @@ export function allowScopedScripts(character) {
133133
134134/**
135135 * Disallow character's regexes to be used; if character is undefined, do nothing
136136 * @param {import('../../char-data.js').v1CharDataCharacter|undefined} character
137137 * @returns {void}
138138 */
139139export function disallowScopedScripts(character) {
public/scripts/group-chats.js+306 -86
@@ -17,6 +17,8 @@ import {
1717 renderPaginationDropdown,
1818 paginationDropdownChangeHandler,
1919 waitUntilCondition,
20+ uuidv4,
21+ humanFileSize,
2022} from './utils.js';
2123import { RA_CountCharTokens, humanizedDateTime, dragElement, favsToHotswap, getMessageTimeStamp } from './RossAscends-mods.js';
2224import { power_user, loadMovingUIState, sortEntitiesList } from './power-user.js';
@@ -109,7 +111,9 @@ export {
109111let is_group_generating = false; // Group generation flag
110112let is_group_automode_enabled = false;
111113let hideMutedSprites = false;
114+/** @type {Group[]} */
112115let groups = [];
116+/** @type {string|null} */
113117let selected_group = null;
114118let group_generation_id = null;
115119let fav_grp_checked = false;
@@ -143,6 +147,11 @@ function setAutoModeWorker() {
143147 autoModeWorker = setInterval(groupChatAutoModeWorker, autoModeDelay * 1000);
144148}
145149
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+ */
146155async function _save(group, reload = true) {
147156 await fetch('/api/groups/edit', {
148157 method: 'POST',
@@ -179,6 +188,11 @@ async function regenerateGroup() {
179188 generateGroupWrapper(false, 'normal', { signal: abortController.signal });
180189}
181190
191+/**
192+ * Loads group chat messages from the server.
193+ * @param {string} chatId Chat ID
194+ * @returns {Promise<ChatFile>} Array of chat messages
195+ */
182196async function loadGroupChat(chatId) {
183197 const response = await fetch('/api/chats/group/get', {
184198 method: 'POST',
@@ -188,12 +202,20 @@ async function loadGroupChat(chatId) {
188202
189203 if (response.ok) {
190204 const data = await response.json();
205+ if (!Array.isArray(data)) {
206+ return [];
207+ }
191208 return data;
192209 }
193210
194211 return [];
195212}
196213
214+/**
215+ * Validates a group by checking if all members exist and removing duplicates.
216+ * @param {Group} group Group to validate
217+ * @returns {Promise<void>}
218+ */
197219async function validateGroup(group) {
198220 if (!group) return;
199221
@@ -225,6 +247,12 @@ async function validateGroup(group) {
225247 }
226248}
227249
250+/**
251+ * Loads the chat messages for a specific group.
252+ * @param {string} groupId - The ID of the group to load chat messages for.
253+ * @param {boolean} reload - Whether to reload the group chat after loading.
254+ * @returns {Promise<void>} A promise that resolves when the chat messages have been loaded.
255+ */
228256export async function getGroupChat(groupId, reload = false) {
229257 const group = groups.find((x) => x.id === groupId);
230258 if (!group) {
@@ -238,9 +266,19 @@ export async function getGroupChat(groupId, reload = false) {
238266
239267 const chat_id = group.chat_id;
240268 const data = await loadGroupChat(chat_id);
241269 const metadata = groupdata?.[0]?.chat_metadata ?? {};
242270 const freshChat = !metadata.tainted && (!Array.isArray(data) || !data.length);
243271
272+ // Remove chat file header if present
273+ if (Array.isArray(data) && data.length && Object.hasOwn(data[0], 'chat_metadata')) {
274+ data.shift();
275+ }
276+
277+ // Add integrity slug if missing
278+ if (!metadata['integrity']) {
279+ metadata['integrity'] = uuidv4();
280+ }
281+
244282 await loadItemizedPrompts(getCurrentChatId());
245283
246284 if (group && Array.isArray(group.members) && freshChat) {
@@ -266,7 +304,6 @@ export async function getGroupChat(groupId, reload = false) {
266304 }
267305 await saveGroupChat(groupId, false);
268306 } else if (Array.isArray(data) && data.length) {
269- data[0].is_group = true;
270307 chat.splice(0, chat.length, ...data);
271308 chat.forEach(ensureMessageMediaIsArray);
272309 chatElement.find('.mes').remove();
@@ -528,6 +565,11 @@ export function getGroupCharacterCards(groupId, characterId) {
528565 return { description, personality, scenario, mesExamples };
529566}
530567
568+/**
569+ * Gets the first message for a character.
570+ * @param {Character} character Character object
571+ * @returns {Promise<ChatMessage>} First message object
572+ */
531573async function getFirstCharacterMessage(character) {
532574 let messageText = character.first_mes;
533575
@@ -566,20 +608,53 @@ function resetSelectedGroup() {
566608 is_group_generating = false;
567609}
568610
569-async function saveGroupChat(groupId, shouldSaveGroup) {
611+/**
612+ * Saves a group chat to the server.
613+ * @param {string} groupId Group ID
614+ * @param {boolean} shouldSaveGroup Whether to save the group after saving the chat
615+ * @param {boolean} force Force the saving on integrity error
616+ * @returns {Promise<void>} A promise that resolves when the group chat has been saved.
617+ */
618+async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
570619 const group = groups.find(x => x.id == groupId);
571620 const chat_id = group.chat_id;
572621 group['date_last_chat'] = Date.now();
622+ /** @type {ChatHeader} */
623+ const chatHeader = {
624+ chat_metadata: { ...chat_metadata },
625+ };
573626 const response = await fetch('/api/chats/group/save', {
574627 method: 'POST',
575628 headers: getRequestHeaders(),
576629 body: JSON.stringify({ id: chat_id, chat: [chatHeader, ...chat], force: force }),
577630 });
578631
579632 if (!response.ok) {
580- toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group Chat could not be saved`);
633+ const errorData = await response.json();
581- console.error('Group chat could not be saved', response);
634+ const isIntegrityError = errorData?.error === 'integrity' && !force;
582- return;
635+ if (!isIntegrityError) {
636+ toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group Chat could not be saved`);
637+ console.error('Group chat could not be saved', response);
638+ return;
639+ }
640+
641+ const popupResult = await Popup.show.input(
642+ t`ERROR: Chat integrity check failed while saving the file.`,
643+ t`<p>After you click OK, the page will be reloaded to prevent data corruption.</p>
644+ <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>`,
645+ '',
646+ { okButton: 'OK', cancelButton: false },
647+ );
648+
649+ const forceSaveConfirmed = popupResult === 'OVERWRITE';
650+
651+ if (!forceSaveConfirmed) {
652+ console.warn('Chat integrity check failed, and user did not confirm the overwrite. Reloading the page.');
653+ window.location.reload();
654+ return;
655+ }
656+
657+ await saveGroupChat(groupId, shouldSaveGroup, true);
583658 }
584659
585660 if (shouldSaveGroup) {
@@ -587,6 +662,12 @@ async function saveGroupChat(groupId, shouldSaveGroup) {
587662 }
588663}
589664
665+/**
666+ * Renames a group member across all groups and their chats.
667+ * @param {string} oldAvatar Old avatar name
668+ * @param {string} newAvatar New avatar name
669+ * @param {string} newName New character name
670+ */
590671export async function renameGroupMember(oldAvatar, newAvatar, newName) {
591672 // Scan every group for our renamed character
592673 for (const group of groups) {
@@ -614,6 +695,11 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) {
614695 if (Array.isArray(messages) && messages.length) {
615696 // Iterate over every chat message
616697 for (const message of messages) {
698+ // Skip the chat header
699+ if (Object.hasOwn(message, 'chat_metadata')) {
700+ continue;
701+ }
702+
617703 // Only look at character messages
618704 if (message.is_user || message.is_system) {
619705 continue;
@@ -652,6 +738,9 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) {
652738 }
653739}
654740
741+/**
742+ * Fetches all groups from the server and processes them.
743+ */
655744async function getGroups() {
656745 const response = await fetch('/api/groups/all', {
657746 method: 'POST',
@@ -659,8 +748,9 @@ async function getGroups() {
659748 });
660749
661750 if (response.ok) {
751+ /** @type {Group[]} */
662752 const data = await response.json();
663- groups = data.sort((a, b) => a.id - b.id);
753+ groups = data.slice();
664754
665755 // Convert groups to new format
666756 for (const group of groups) {
@@ -678,9 +768,6 @@ async function getGroups() {
678768 .filter(x => x)
679769 .filter(onlyUnique);
680770 }
681- if (group.past_metadata == undefined) {
682- group.past_metadata = {};
683- }
684771 if (typeof group.chat_id === 'number') {
685772 group.chat_id = String(group.chat_id);
686773 }
@@ -691,6 +778,11 @@ async function getGroups() {
691778 }
692779}
693780
781+/**
782+ * Gets a group UI block for the list.
783+ * @param {Group} group Group object
784+ * @returns {JQuery<HTMLElement>} jQuery element representing the group block
785+ */
694786export function getGroupBlock(group) {
695787 let count = 0;
696788 let namesList = [];
@@ -712,7 +804,7 @@ export function getGroupBlock(group) {
712804 template.find('.ch_name').text(group.name).attr('title', `[Group] ${group.name}`);
713805 template.find('.group_fav_icon').css('display', 'none');
714806 template.addClass(group.fav ? 'is_fav' : '');
715807 template.find('.ch_fav').val(String(group.fav));
716808 template.find('.group_select_counter').text(count + ' ' + (count != 1 ? t`characters` : t`character`));
717809 template.find('.group_select_block_list').text(namesList.join(', '));
718810
@@ -728,6 +820,10 @@ export function getGroupBlock(group) {
728820 return template;
729821}
730822
823+/**
824+ * Updates the avatar display for a given group.
825+ * @param {Group} group Group object
826+ */
731827function updateGroupAvatar(group) {
732828 $('#group_avatar_preview').empty().append(getGroupAvatar(group));
733829
@@ -740,7 +836,11 @@ function updateGroupAvatar(group) {
740836 favsToHotswap();
741837}
742838
743-// check if isDataURLor if it's a valid local file url
839+/**
840+ * Checks if a URL is a valid image URL.
841+ * @param {string} url URL to check
842+ * @returns {boolean} True if valid, false otherwise
843+ */
744844function isValidImageUrl(url) {
745845 // check if empty dict
746846 if (Object.keys(url).length === 0) {
@@ -749,6 +849,11 @@ function isValidImageUrl(url) {
749849 return isDataURL(url) || (url && (url.startsWith('user') || url.startsWith('/user')));
750850}
751851
852+/**
853+ * Gets a group avatar element.
854+ * @param {Group} group Group object
855+ * @returns {JQuery<HTMLElement>} Group avatar element
856+ */
752857function getGroupAvatar(group) {
753858 if (!group) {
754859 return $(`<div class="avatar"><img src="${default_avatar}"></div>`);
@@ -797,6 +902,11 @@ function getGroupAvatar(group) {
797902 return groupAvatar;
798903}
799904
905+/**
906+ * Gets chat IDs for a group.
907+ * @param {string} groupId Group ID
908+ * @returns {string[]} Array of chat IDs
909+ */
800910function getGroupChatNames(groupId) {
801911 const group = groups.find(x => x.id === groupId);
802912
@@ -811,7 +921,14 @@ function getGroupChatNames(groupId) {
811921 return names;
812922}
813923
814-async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
924+/**
925+ * Generates text for the group chat by queueing members according to the activation strategy.
926+ * @param {boolean} byAutoMode If the generation was triggered by the auto mode.
927+ * @param {string?} type Generation type
928+ * @param {object} params Additional Generate parameters
929+ * @returns {Promise<string|void>} Generated text or nothing if no generation occurred
930+ */
931+async function generateGroupWrapper(byAutoMode, type = null, params = {}) {
815932 function throwIfAborted() {
816933 if (params.signal instanceof AbortSignal && params.signal.aborted) {
817934 throw new Error('AbortSignal was fired. Group generation stopped');
@@ -830,7 +947,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
830947
831948 // Auto-navigate back to group menu
832949 if (menu_type !== 'group_edit') {
833950 select_group_chats(selected_group, false);
834951 await delay(1);
835952 }
836953
@@ -859,7 +976,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
859976 let activationText = '';
860977 let isUserInput = false;
861978
862979 if (userInput?.length && !by_auto_modebyAutoMode) {
863980 isUserInput = true;
864981 activationText = userInput;
865982 } else {
@@ -935,12 +1052,12 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
9351052
9361053 // Wait for generation to finish
9371054 const generateType = ['swipe', 'impersonate', 'quiet', 'continue'].includes(type) ? type : 'normal';
9381055 textResult = await Generate(generateType, { automatic_trigger: by_auto_modebyAutoMode, ...(params || {}) });
9391056 let messageChunk = textResult?.messageChunk;
9401057
9411058 if (messageChunk) {
9421059 while (shouldAutoContinue(messageChunk, type === 'impersonate')) {
9431060 textResult = await Generate('continue', { automatic_trigger: by_auto_modebyAutoMode, ...(params || {}) });
9441061 messageChunk = textResult?.messageChunk;
9451062 }
9461063 }
@@ -966,6 +1083,10 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
9661083 return Promise.resolve(textResult);
9671084}
9681085
1086+/**
1087+ * Gets the generation ID of the last chat message.
1088+ * @returns {number|null} Generation ID or null
1089+ */
9691090function getLastMessageGenerationId() {
9701091 let generationId = null;
9711092 if (chat.length > 0) {
@@ -977,6 +1098,11 @@ function getLastMessageGenerationId() {
9771098 return generationId;
9781099}
9791100
1101+/**
1102+ * Activate group chat members for 'impersonate' generation type.
1103+ * @param {string[]} members Array of group member avatar ids
1104+ * @returns {number[]} Array of character ids
1105+ */
9801106function activateImpersonate(members) {
9811107 const randomIndex = Math.floor(Math.random() * members.length);
9821108 const activatedMembers = [members[randomIndex]];
@@ -1039,6 +1165,11 @@ function activateSwipe(members, { allowSystem = false } = {}) {
10391165 return memberIds;
10401166}
10411167
1168+/**
1169+ * Activate group members for the list activation order.
1170+ * @param {string[]} members Array of group member avatar ids
1171+ * @returns {number[]} Array of character ids
1172+ */
10421173function activateListOrder(members) {
10431174 let activatedMembers = members.filter(onlyUnique);
10441175
@@ -1092,6 +1223,15 @@ function activatePooledOrder(members, lastMessage, isUserInput) {
10921223 return memberId !== -1 ? [memberId] : [];
10931224}
10941225
1226+/**
1227+ * Activate group members for the natural activation order.
1228+ * @param {string[]} members Array of group member avatar ids
1229+ * @param {string} input User input that triggered the generation
1230+ * @param {ChatMessage} lastMessage Last message in the chat
1231+ * @param {boolean} allowSelfResponses If the group allows self-responses
1232+ * @param {boolean} isUserInput If the generation was triggered by user input
1233+ * @returns {number[]} Array of character ids
1234+ */
10951235function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, isUserInput) {
10961236 let activatedMembers = [];
10971237
@@ -1168,6 +1308,11 @@ function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, i
11681308 return memberIds;
11691309}
11701310
1311+/**
1312+ * Deletes a group from the server by ID.
1313+ * @param {string} id Group ID to delete
1314+ * @returns {Promise<void>} Promise that resolves when the group is deleted
1315+ */
11711316async function deleteGroup(id) {
11721317 const group = groups.find((x) => x.id === id);
11731318
@@ -1197,6 +1342,13 @@ async function deleteGroup(id) {
11971342 }
11981343}
11991344
1345+/**
1346+ * Edits a group by ID.
1347+ * @param {string} id Group ID to edit
1348+ * @param {boolean} immediately Whether to save immediately
1349+ * @param {boolean} reload Whether to reload the groups after saving
1350+ * @returns {Promise<void>} Promise that resolves when the group is edited
1351+ */
12001352export async function editGroup(id, immediately, reload = true) {
12011353 let group = groups.find((x) => x.id === id);
12021354
@@ -1204,11 +1356,6 @@ export async function editGroup(id, immediately, reload = true) {
12041356 return;
12051357 }
12061358
1207- if (id === selected_group) {
1208- // structuredClone may cause issues if metadata has non-cloneable references
1209- group['chat_metadata'] = JSON.parse(JSON.stringify(chat_metadata));
1210- }
1211-
12121359 if (immediately) {
12131360 return await _save(group, reload);
12141361 }
@@ -1260,6 +1407,12 @@ async function groupChatAutoModeWorker() {
12601407 await generateGroupWrapper(true, 'auto', { signal: groupAutoModeAbortController.signal });
12611408}
12621409
1410+/**
1411+ * Modifies a group member by adding or removing them.
1412+ * @param {string} groupId Group ID
1413+ * @param {JQuery<HTMLElement>} groupMember Group member element
1414+ * @param {boolean} isDelete If true, removes the member; otherwise adds the member
1415+ */
12631416async function modifyGroupMember(groupId, groupMember, isDelete) {
12641417 const id = groupMember.data('id');
12651418 const thisGroup = groups.find((x) => x.id == groupId);
@@ -1287,9 +1440,16 @@ async function modifyGroupMember(groupId, groupMember, isDelete) {
12871440 $('#rm_group_submit').prop('disabled', !groupHasMembers);
12881441}
12891442
1290-async function reorderGroupMember(chat_id, groupMember, direction) {
1443+/**
1444+ * Reorders a group member up or down.
1445+ * @param {string} groupId Group ID
1446+ * @param {JQuery<HTMLElement>} groupMember Group member element
1447+ * @param {string} direction Direction to move the member ('up' or 'down')
1448+ * @returns {Promise<void>} Promise that resolves when the member has been reordered
1449+ */
1450+async function reorderGroupMember(groupId, groupMember, direction) {
12911451 const id = groupMember.data('id');
12921452 const thisGroup = groups.find((x) => x.id == chat_idgroupId);
12931453 const memberArray = thisGroup?.members ?? newGroupMembers;
12941454
12951455 const indexOf = memberArray.indexOf(id);
@@ -1312,7 +1472,7 @@ async function reorderGroupMember(chat_id, groupMember, direction) {
13121472
13131473 // Existing groups need to modify members list
13141474 if (openGroupId) {
13151475 await editGroup(chat_idgroupId, false, false);
13161476 updateGroupAvatar(thisGroup);
13171477 }
13181478}
@@ -1358,10 +1518,16 @@ async function onGroupNameInput() {
13581518 let _thisGroup = groups.find((x) => x.id == openGroupId);
13591519 _thisGroup.name = $(this).val();
13601520 $('#rm_button_selected_ch').children('h2').text(_thisGroup.name);
13611521 await editGroup(openGroupId, false);
13621522 }
13631523}
13641524
1525+/**
1526+ * Checks if a character with the given avatar ID is a member of the group.
1527+ * @param {Group} group Group object
1528+ * @param {string} avatarId Avatar ID to check
1529+ * @returns {boolean} True if the avatar is a member of the group, false otherwise
1530+ */
13651531function isGroupMember(group, avatarId) {
13661532 if (group && Array.isArray(group.members)) {
13671533 return group.members.includes(avatarId);
@@ -1370,6 +1536,13 @@ function isGroupMember(group, avatarId) {
13701536 }
13711537}
13721538
1539+/**
1540+ * Gets group characters based on filters.
1541+ * @param {object} param
1542+ * @param {boolean} [param.doFilter=false] Whether to apply filters
1543+ * @param {boolean} [param.onlyMembers=false] Whether to include only group members
1544+ * @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects
1545+ */
13731546function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {
13741547 function sortMembersFn(a, b) {
13751548 const membersArray = thisGroup?.members ?? newGroupMembers;
@@ -1461,15 +1634,20 @@ function printGroupMembers() {
14611634 });
14621635}
14631636
1637+/**
1638+ * Creates a jQuery element representing a group character block.
1639+ * @param {Character} character Character object
1640+ * @returns {JQuery<HTMLElement>} jQuery element representing the group character block
1641+ */
14641642function getGroupCharacterBlock(character) {
14651643 const avatar = getThumbnailUrl('avatar', character.avatar);
14661644 const template = $('#group_member_template .group_member').clone();
14671645 const isFav = !!character.fav || character.fav == 'true';
14681646 template.data('id', character.avatar);
14691647 template.find('.avatar img').attr({ 'src': avatar, 'title': character.avatar });
14701648 template.find('.ch_name').text(character.name);
14711649 template.attr('data-chid', characters.indexOf(character));
14721650 template.find('.ch_fav').val(String(isFav));
14731651 template.toggleClass('is_fav', isFav);
14741652
14751653 const auxFieldName = power_user.aux_field || 'character_version';
@@ -1503,6 +1681,11 @@ function getGroupCharacterBlock(character) {
15031681 return template;
15041682}
15051683
1684+/**
1685+ * Checks if a group member is disabled.
1686+ * @param {string} avatarId Avatar ID of the group member
1687+ * @returns {boolean} True if the group member is disabled, false otherwise
1688+ */
15061689function isGroupMemberDisabled(avatarId) {
15071690 const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
15081691 return Boolean(thisGroup && thisGroup.disabled_members.includes(avatarId));
@@ -1553,6 +1736,11 @@ async function onHideMutedSpritesClick(value) {
15531736 }
15541737}
15551738
1739+/**
1740+ * Toggles the visibility of hidden controls based on the group's generation mode.
1741+ * @param {Group} group Group object
1742+ * @param {number|null} generationMode Generation mode, or null to use the group's current generation mode
1743+ */
15561744function toggleHiddenControls(group, generationMode = null) {
15571745 const isJoin = [group_generation_mode.APPEND, group_generation_mode.APPEND_DISABLED].includes(generationMode ?? group?.generation_mode);
15581746 $('#rm_group_generation_mode_join_prefix').parent().toggle(isJoin);
@@ -1564,6 +1752,11 @@ function toggleHiddenControls(group, generationMode = null) {
15641752 }
15651753}
15661754
1755+/**
1756+ * Opens a group creation/editing right menu.
1757+ * @param {string|null} groupId ID of the group to select or null if creating a new group
1758+ * @param {boolean} skipAnimation If true, skips the animation when selecting the group
1759+ */
15671760function select_group_chats(groupId, skipAnimation) {
15681761 openGroupId = groupId;
15691762 newGroupMembers = [];
@@ -1774,6 +1967,11 @@ function updateFavButtonState(state) {
17741967 $('#group_favorite_button').toggleClass('fav_off', !fav_grp_checked);
17751968}
17761969
1970+/**
1971+ * Opens a group chat by its ID and updates the UI accordingly.
1972+ * @param {string} groupId ID of the group to open
1973+ * @returns {Promise<boolean>} Whether the group was opened
1974+ */
17771975export async function openGroupById(groupId) {
17781976 if (isChatSaving) {
17791977 toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`);
@@ -1786,7 +1984,7 @@ export async function openGroupById(groupId) {
17861984 }
17871985
17881986 if (!is_send_press && !is_group_generating) {
17891987 select_group_chats(groupId, false);
17901988
17911989 if (selected_group !== groupId) {
17921990 groupChatQueueOrder = new Map();
@@ -1806,6 +2004,11 @@ export async function openGroupById(groupId) {
18062004 return false;
18072005}
18082006
2007+/**
2008+ * Peeks the character definition from a group member element.
2009+ * @param {JQuery<HTMLElement>} characterSelect Character select element
2010+ * @returns {Promise<void>}
2011+ */
18092012async function openCharacterDefinition(characterSelect) {
18102013 if (is_group_generating) {
18112014 toastr.warning(t`Can't peek a character while group reply is being generated`);
@@ -1834,7 +2037,7 @@ function filterGroupMembers() {
18342037}
18352038
18362039async function createGroup() {
18372040 let name = $('#rm_group_chat_name').val().toString();
18382041 let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');
18392042 let activationStrategy = Number($('#rm_group_activation_strategy').find(':selected').val()) ?? group_activation_strategy.NATURAL;
18402043 let generationMode = Number($('#rm_group_generation_mode').find(':selected').val()) ?? group_generation_mode.SWAP;
@@ -1846,29 +2049,30 @@ async function createGroup() {
18462049 name = t`Group: ${memberNames}`;
18472050 }
18482051
18492052 const avatar_urlavatarUrl = $('#group_avatar_preview img').attr('src');
1850-
18512053 const chatName = humanizedDateTime();
18522054 const chats = [chatName];
18532055
2056+ /** @type {Omit<Group, 'id'>} */
2057+ const groupCreateModel = {
2058+ name: name,
2059+ members: members,
2060+ avatar_url: isValidImageUrl(avatarUrl) ? avatarUrl : default_avatar,
2061+ allow_self_responses: allowSelfResponses,
2062+ hideMutedSprites: hideMutedSprites,
2063+ activation_strategy: activationStrategy,
2064+ generation_mode: generationMode,
2065+ disabled_members: [],
2066+ fav: fav_grp_checked,
2067+ chat_id: chatName,
2068+ chats: chats,
2069+ auto_mode_delay: autoModeDelay,
2070+ };
2071+
18542072 const createGroupResponse = await fetch('/api/groups/create', {
18552073 method: 'POST',
18562074 headers: getRequestHeaders(),
18572075 body: JSON.stringify({groupCreateModel),
1858- name: name,
1859- members: members,
1860- avatar_url: isValidImageUrl(avatar_url) ? avatar_url : default_avatar,
1861- allow_self_responses: allowSelfResponses,
1862- hideMutedSprites: hideMutedSprites,
1863- activation_strategy: activationStrategy,
1864- generation_mode: generationMode,
1865- disabled_members: [],
1866- chat_metadata: {},
1867- fav: fav_grp_checked,
1868- chat_id: chatName,
1869- chats: chats,
1870- auto_mode_delay: autoModeDelay,
1871- }),
18722076 });
18732077
18742078 if (createGroupResponse.ok) {
@@ -1880,6 +2084,11 @@ async function createGroup() {
18802084 }
18812085}
18822086
2087+/**
2088+ * Creates a new group chat within the specified group.
2089+ * @param {string} groupId Group ID
2090+ * @returns {Promise<void>} Promise that resolves when the new group chat is created
2091+ */
18832092export async function createNewGroupChat(groupId) {
18842093 const group = groups.find(x => x.id === groupId);
18852094
@@ -1887,27 +2096,22 @@ export async function createNewGroupChat(groupId) {
18872096 return;
18882097 }
18892098
1890- const oldChatName = group.chat_id;
1891- const newChatName = humanizedDateTime();
1892-
1893- if (typeof group.past_metadata !== 'object') {
1894- group.past_metadata = {};
1895- }
1896-
18972099 await clearChat();
18982100 chat.length = 0;
1899- if (oldChatName) {
2101+ const newChatName = humanizedDateTime();
1900- group.past_metadata[oldChatName] = Object.assign({}, chat_metadata);
1901- }
19022102 group.chats.push(newChatName);
19032103 group.chat_id = newChatName;
1904- group.chat_metadata = {};
2104+ updateChatMetadata({}, true);
1905- updateChatMetadata(group.chat_metadata, true);
19062105
19072106 await editGroup(group.id, true, false);
19082107 await getGroupChat(group.id);
19092108}
19102109
2110+/**
2111+ * Retrieves past chats for a specified group.
2112+ * @param {string} groupId Group ID
2113+ * @returns {Promise<Array>} Array of past chats
2114+ */
19112115export async function getGroupPastChats(groupId) {
19122116 const group = groups.find(x => x.id === groupId);
19132117
@@ -1920,16 +2124,22 @@ export async function getGroupPastChats(groupId) {
19202124 try {
19212125 for (const chatId of group.chats) {
19222126 const messages = await loadGroupChat(chatId);
1923- let this_chat_file_size = (JSON.stringify(messages).length / 1024).toFixed(2) + 'kb';
2127+ if (!Array.isArray(messages)) {
1924- let chat_items = messages.length;
2128+ continue;
2129+ }
2130+ const fileSize = humanFileSize(JSON.stringify(messages).length);
2131+ if (messages.length > 0 && Object.hasOwn(messages[0], 'chat_metadata')) {
2132+ messages.shift();
2133+ }
2134+ const chatItems = messages.length;
19252135 const lastMessage = messages.length ? messages[messages.length - 1].mes : '[The chat is empty]';
19262136 const lastMessageDate = messages.length ? (messages[messages.length - 1].send_date || Date.now()) : Date.now();
19272137 chats.push({
19282138 'file_name': chatId,
19292139 'mes': lastMessage,
19302140 'last_mes': lastMessageDate,
19312141 'file_size': this_chat_file_sizefileSize,
19322142 'chat_items': chat_itemschatItems,
19332143 });
19342144 }
19352145 } catch (err) {
@@ -1938,6 +2148,12 @@ export async function getGroupPastChats(groupId) {
19382148 return chats;
19392149}
19402150
2151+/**
2152+ * Opens a specific group chat for the specified group by its ID.
2153+ * @param {string} groupId Group ID
2154+ * @param {string} chatId Chat ID
2155+ * @returns {Promise<void>}
2156+ */
19412157export async function openGroupChat(groupId, chatId) {
19422158 await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
19432159 const group = groups.find(x => x.id === groupId);
@@ -1948,17 +2164,21 @@ export async function openGroupChat(groupId, chatId) {
19482164
19492165 await clearChat();
19502166 chat.length = 0;
1951- const previousChat = group.chat_id;
1952- group.past_metadata[previousChat] = Object.assign({}, chat_metadata);
19532167 group.chat_id = chatId;
1954- group.chat_metadata = group.past_metadata[chatId] || {};
19552168 group['date_last_chat'] = Date.now();
19562169 updateChatMetadata(group.chat_metadata{}, true);
19572170
19582171 await editGroup(groupId, true, false);
19592172 await getGroupChat(groupId);
19602173}
19612174
2175+/**
2176+ * Renames a group chat within the specified group.
2177+ * @param {string} groupId Group ID
2178+ * @param {string} oldChatId Old chat ID
2179+ * @param {string} newChatId New chat ID
2180+ * @returns {Promise<void>} Promise that resolves when the group chat is renamed
2181+ */
19622182export async function renameGroupChat(groupId, oldChatId, newChatId) {
19632183 const group = groups.find(x => x.id === groupId);
19642184
@@ -1972,8 +2192,6 @@ export async function renameGroupChat(groupId, oldChatId, newChatId) {
19722192
19732193 group.chats.splice(group.chats.indexOf(oldChatId), 1);
19742194 group.chats.push(newChatId);
1975- group.past_metadata[newChatId] = (group.past_metadata[oldChatId] || {});
1976- delete group.past_metadata[oldChatId];
19772195
19782196 await editGroup(groupId, true, true);
19792197}
@@ -1990,12 +2208,7 @@ export async function deleteGroupChatByName(groupId, chatName) {
19902208 return;
19912209 }
19922210
1993- if (typeof group.past_metadata !== 'object') {
1994- group.past_metadata = {};
1995- }
1996-
19972211 group.chats.splice(group.chats.indexOf(chatName), 1);
1998- delete group.past_metadata[chatName];
19992212
20002213 const response = await fetch('/api/chats/group/delete', {
20012214 method: 'POST',
@@ -2011,12 +2224,8 @@ export async function deleteGroupChatByName(groupId, chatName) {
20112224
20122225 // If the deleted chat was the current chat, switch to the last chat in the group
20132226 if (group.chat_id === chatName) {
2014- group.chat_id = '';
2015- group.chat_metadata = {};
2016-
20172227 const newChatName = group.chats.length ? group.chats[group.chats.length - 1] : humanizedDateTime();
20182228 group.chat_id = newChatName;
2019- group.chat_metadata = group.past_metadata[newChatName] || {};
20202229 }
20212230
20222231 await editGroup(groupId, true, true);
@@ -2038,12 +2247,10 @@ export async function deleteGroupChat(groupId, chatId, { jumpToNewChat = true }
20382247 }
20392248
20402249 group.chats.splice(group.chats.indexOf(chatId), 1);
2041- delete group.past_metadata[chatId];
20422250
20432251 if (group.chat_id === chatId) {
20442252 group.chat_id = '';
2045- group.chat_metadata = {};
2253+ updateChatMetadata({}, true);
2046- updateChatMetadata(group.chat_metadata, true);
20472254 }
20482255
20492256 const response = await fetch('/api/chats/group/delete', {
@@ -2101,6 +2308,14 @@ export async function importGroupChat(formData, { refresh = true } = {}) {
21012308 return [];
21022309}
21032310
2311+/**
2312+ * Saves the current group chat as a bookmark chat.
2313+ * @param {string} groupId Group ID
2314+ * @param {string} name Name of the chat to save
2315+ * @param {ChatMetadata?} metadata New metadata to save with the chat
2316+ * @param {number|undefined} mesId Optional message ID to trim the chat up to
2317+ * @returns {Promise<void>} Promise that resolves when the group chat is saved
2318+ */
21042319export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
21052320 const group = groups.find(x => x.id === groupId);
21062321
@@ -2108,11 +2323,16 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
21082323 return;
21092324 }
21102325
2111- group.past_metadata[name] = { ...chat_metadata, ...(metadata || {}) };
21122326 group.chats.push(name);
21132327
2114- const trimmed_chat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
2328+ /** @type {ChatHeader} */
2115- ? chat.slice(0, parseInt(mesId) + 1)
2329+ const chatHeader = {
2330+ chat_metadata: { ...chat_metadata, ...(metadata || {}) },
2331+ };
2332+
2333+ /** @type {ChatMessage[]} */
2334+ const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
2335+ ? chat.slice(0, Number(mesId) + 1)
21162336 : chat;
21172337
21182338 await editGroup(groupId, true, false);
@@ -2120,7 +2340,7 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
21202340 const response = await fetch('/api/chats/group/save', {
21212341 method: 'POST',
21222342 headers: getRequestHeaders(),
21232343 body: JSON.stringify({ id: name, chat: [chatHeader, ...trimmed_chattrimmedChat] }),
21242344 });
21252345
21262346 if (!response.ok) {
public/scripts/utils.js+1 -1
@@ -2558,7 +2558,7 @@ export function findPersona({ name = null, allowAvatar = true, insensitive = tru
25582558 * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by
25592559 * @param {boolean} [options.preferCurrentChar=true] - Whether to prefer the current character(s)
25602560 * @param {boolean} [options.quiet=false] - Whether to suppress warnings
25612561 * @returns {import('./char-data.js').v1CharDataCharacter?} - The found character or null if not found
25622562 */
25632563export function findChar({ name = null, allowAvatar = true, insensitive = true, filteredByTags = null, preferCurrentChar = true, quiet = false } = {}) {
25642564 const matches = (char) => !name || (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name);
public/scripts/welcome-screen.js+2 -2
@@ -106,7 +106,7 @@ async function unshallowPermanentAssistant() {
106106
107107/**
108108 * Returns a greeting message for the assistant based on the character.
109109 * @param {import('./char-data.js').v1CharDataCharacter} character Character data
110110 * @returns {string} Greeting message
111111*/
112112function getAssistantGreeting(character) {
@@ -623,7 +623,7 @@ export function assignCharacterAsAssistant(characterId) {
623623 if (characterId === undefined) {
624624 return;
625625 }
626626 /** @type {import('./char-data.js').v1CharDataCharacter} */
627627 const character = characters[characterId];
628628 if (!character) {
629629 return;
src/endpoints/characters.js+1 -1
@@ -1383,7 +1383,7 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
13831383 const jsonFilesPromise = jsonFiles.map((file) => {
13841384 const withMetadata = !!request.body.metadata;
13851385 const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
13861386 return getChatInfo(pathToFile, {}, false, withMetadata);
13871387 });
13881388
13891389 const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
src/endpoints/chats.js+35 -27
@@ -367,11 +367,10 @@ async function checkChatIntegrity(filePath, integritySlug) {
367367 * Reads the information from a chat file.
368368 * @param {string} pathToFile - Path to the chat file
369369 * @param {object} additionalData - Additional data to include in the result
370- * @param {boolean} isGroup - Whether the chat is a group chat
371370 * @param {boolean} withMetadata - Whether to read chat metadata
372371 * @returns {Promise<ChatInfo>}
373372 */
374373export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false, withMetadata = false) {
375374 return new Promise(async (res) => {
376375 const parsedPath = path.parse(pathToFile);
377376 const stats = await fs.promises.stat(pathToFile);
@@ -387,13 +386,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, isGroup = fal
387386 ...additionalData,
388387 };
389388
390389 if (stats.size === 0 && !isGroup) {
391- console.warn(`Found an empty chat file: ${pathToFile}`);
392- res({});
393- return;
394- }
395-
396- if (stats.size === 0 && isGroup) {
397390 res(chatData);
398391 return;
399392 }
@@ -422,7 +415,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, isGroup = fal
422415 if (lastLine) {
423416 const jsonData = tryParse(lastLine);
424417 if (jsonData && (jsonData.name || jsonData.character_name || jsonData.chat_metadata)) {
425418 chatData.chat_items = isGroup ? itemCounter : (itemCounter - 1);
426419 chatData.mes = jsonData['mes'] || '[The message is empty]';
427420 chatData.last_mes = jsonData['send_date'] || stats.mtimeMs;
428421
@@ -774,23 +767,38 @@ router.post('/group/delete', (request, response) => {
774767 return response.send({ error: true });
775768});
776769
777770router.post('/group/save', async (request, response) => {
778- if (!request.body || !request.body.id) {
771+ try{
779- return response.sendStatus(400);
772+ if (!request.body || !request.body.id) {
780- }
773+ return response.sendStatus(400);
774+ }
781775
782776 const id = request.body.id;
783777 const pathToFilefilePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`));
784778
785779 if (!fs.existsSync(request.user.directories.groupChats)) {
786780 fs.mkdirSync(request.user.directories.groupChats, { recursive: true });
787781 }
782+
783+ const chatData = request.body.chat;
784+ const jsonlData = chatData.map(JSON.stringify).join('\n');
788785
789- let chat_data = request.body.chat;
786+ if (checkIntegrity && !request.body.force) {
790- let jsonlData = chat_data.map(JSON.stringify).join('\n');
787+ const integritySlug = chatData?.[0]?.chat_metadata?.integrity;
791- writeFileAtomicSync(pathToFile, jsonlData, 'utf8');
788+ const isIntact = await checkChatIntegrity(filePath, integritySlug);
792- getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData);
789+ if (!isIntact) {
793- return response.send({ ok: true });
790+ console.error(`Chat integrity check failed for ${filePath}`);
791+ return response.status(400).send({ error: 'integrity' });
792+ }
793+ }
794+
795+ writeFileAtomicSync(filePath, jsonlData, 'utf8');
796+ getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData);
797+ return response.send({ ok: true });
798+ } catch (error) {
799+ console.error(error);
800+ return response.send({ error: true });
801+ }
794802});
795803
796804router.post('/search', validateAvatarUrlMiddleware, function (request, response) {
@@ -983,10 +991,10 @@ router.post('/recent', async function (request, response) {
983991 const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER);
984992 const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max);
985993 const jsonFilesPromise = recentChats.map((file) => {
986994 const withMetadata = Boolean(!!request.body.metadata);
987995 return file.groupId
988996 ? getChatInfo(file.filePath, { group: file.groupId }, true, withMetadata)
989997 : getChatInfo(file.filePath, { avatar: file.pngFile }, false, withMetadata);
990998 });
991999
9921000 const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
src/endpoints/data-maid.js+13 -0
@@ -579,9 +579,11 @@ export class DataMaidService {
579579 const fileContent = await fs.promises.readFile(pathToFile, 'utf-8');
580580 const groupData = tryParse(fileContent);
581581 if (groupData?.chat_metadata && filterFn(groupData.chat_metadata)) {
582+ console.warn('Found group chat metadata in group definition - this is deprecated behavior.');
582583 allMetadata.push(groupData.chat_metadata);
583584 }
584585 if (groupData?.past_metadata) {
586+ console.warn('Found group past chat metadata in group definition - this is deprecated behavior.');
585587 allMetadata.push(...Object.values(groupData.past_metadata).filter(filterFn));
586588 }
587589 } catch (error) {
@@ -590,6 +592,17 @@ export class DataMaidService {
590592 }
591593 }
592594
595+ const groupChats = await fs.promises.readdir(this.directories.groupChats, { withFileTypes: true });
596+ for (const file of groupChats) {
597+ if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
598+ const chatMessages = await this.#parseChatFile(path.join(this.directories.groupChats, file.name));
599+ const chatMetadata = chatMessages?.[0]?.chat_metadata;
600+ if (chatMetadata && filterFn(chatMetadata)) {
601+ allMetadata.push(chatMetadata);
602+ }
603+ }
604+ }
605+
593606 const chatDirectories = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });
594607 for (const directory of chatDirectories) {
595608 if (directory.isDirectory()) {
src/endpoints/groups.js+104 -3
@@ -1,15 +1,115 @@
11import fs from 'node:fs';
2+import { promises as fsPromises } from 'node:fs';
23import path from 'node:path';
34
45import express from 'express';
56import sanitize from 'sanitize-filename';
67import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic';
78
89import { color, humanizedISO8601DateTime, tryParse } from '../util.js';
910import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1011
1112export const router = express.Router();
1213
14+/**
15+ * Warns if group data contains deprecated metadata keys and removes them.
16+ * @param {object} groupData Group data object
17+ */
18+function warnOnGroupMetadata(groupData) {
19+ if (typeof groupData !== 'object' || groupData === null) {
20+ return;
21+ }
22+ ['chat_metadata', 'past_metadata'].forEach(key => {
23+ if (Object.hasOwn(groupData, key)) {
24+ console.warn(color.yellow(`Group JSON data for "${groupData.id}" contains deprecated key "${key}".`));
25+ delete groupData[key];
26+ }
27+ });
28+}
29+
30+/**
31+ * Migrates group metadata to include chat metadata for each group chat instead of the group itself.
32+ * @param {import('../users.js').UserDirectoryList[]} userDirectories Listing of all users' directories
33+ */
34+export async function migrateGroupChatsMetadataFormat(userDirectories) {
35+ for (const userDirs of userDirectories) {
36+ try {
37+ let anyDataMigrated = false;
38+ const backupPath = path.join(userDirs.backups, '_group_metadata_update');
39+ const groupFiles = await fsPromises.readdir(userDirs.groups, { withFileTypes: true });
40+ const groupChatFiles = await fsPromises.readdir(userDirs.groupChats, { withFileTypes: true });
41+ for (const groupFile of groupFiles) {
42+ try {
43+ const isJsonFile = groupFile.isFile() && path.extname(groupFile.name) === '.json';
44+ if (!isJsonFile) {
45+ continue;
46+ }
47+ const groupFilePath = path.join(userDirs.groups, groupFile.name);
48+ const groupDataRaw = await fsPromises.readFile(groupFilePath, 'utf8');
49+ const groupData = tryParse(groupDataRaw) || {};
50+ const needsMigration = ['chat_metadata', 'past_metadata'].some(key => Object.hasOwn(groupData, key));
51+ if (!needsMigration) {
52+ continue;
53+ }
54+ if (!fs.existsSync(backupPath)){
55+ await fsPromises.mkdir(backupPath, { recursive: true });
56+ }
57+ await fsPromises.copyFile(groupFilePath, path.join(backupPath, groupFile.name));
58+ const allMetadata = {
59+ ...(groupData.past_metadata || {}),
60+ [groupData.chat_id]: (groupData.chat_metadata || {}),
61+ };
62+ if (!Array.isArray(groupData.chats)) {
63+ console.warn(color.yellow(`Group ${groupFile.name} has no chats array, skipping migration.`));
64+ continue;
65+ }
66+ for (const chatId of groupData.chats) {
67+ try {
68+ const chatFileName = sanitize(`${chatId}.jsonl`);
69+ const chatFileDirent = groupChatFiles.find(f => f.isFile() && f.name === chatFileName);
70+ if (!chatFileDirent) {
71+ console.warn(color.yellow(`Group chat file ${chatId} not found, skipping migration.`));
72+ continue;
73+ }
74+ const chatFilePath = path.join(userDirs.groupChats, chatFileName);
75+ const chatMetadata = allMetadata[chatId] || {};
76+ const chatDataRaw = await fsPromises.readFile(chatFilePath, 'utf8');
77+ const chatData = chatDataRaw.split('\n').filter(line => line.trim()).map(line => tryParse(line)).filter(Boolean);
78+ const alreadyHasMetadata = chatData.length > 0 && Object.hasOwn(chatData[0], 'chat_metadata');
79+ if (alreadyHasMetadata) {
80+ console.log(color.yellow(`Group chat ${chatId} already has chat metadata, skipping update.`));
81+ continue;
82+ }
83+ await fsPromises.copyFile(chatFilePath, path.join(backupPath, chatFileName));
84+ const chatHeader = { chat_metadata: chatMetadata };
85+ const newChatData = [chatHeader, ...chatData];
86+ const newChatDataRaw = newChatData.map(entry => JSON.stringify(entry)).join('\n');
87+ await writeFileAtomic(chatFilePath, newChatDataRaw, 'utf8');
88+ console.log(`Updated group chat data format for ${chatId}`);
89+ anyDataMigrated = true;
90+ } catch (chatError) {
91+ console.error(color.red(`Could not update existing chat data for ${chatId}`), chatError);
92+ }
93+ }
94+ delete groupData.chat_metadata;
95+ delete groupData.past_metadata;
96+ await writeFileAtomic(groupFilePath, JSON.stringify(groupData, null, 4), 'utf8');
97+ console.log(`Migrated group chats metadata for group: ${groupData.id}`);
98+ anyDataMigrated = true;
99+ } catch (groupError) {
100+ console.error(color.red(`Could not process group file ${groupFile.name}`), groupError);
101+ }
102+ }
103+ if (anyDataMigrated) {
104+ console.log(color.green(`Completed migration of group chats metadata for user at ${userDirs.root}`));
105+ console.log(color.cyan(`Backups of modified files are located at ${backupPath}`));
106+ }
107+ } catch (directoryError) {
108+ console.error(color.red(`Error migrating group chats metadata for user at ${userDirs.root}`), directoryError);
109+ }
110+ }
111+}
112+
13113router.post('/all', (request, response) => {
14114 const groups = [];
15115
@@ -59,6 +159,7 @@ router.post('/create', (request, response) => {
59159 return response.sendStatus(400);
60160 }
61161
162+ warnOnGroupMetadata(request.body);
62163 const id = String(Date.now());
63164 const groupMetadata = {
64165 id: id,
@@ -69,7 +170,6 @@ router.post('/create', (request, response) => {
69170 activation_strategy: request.body.activation_strategy ?? 0,
70171 generation_mode: request.body.generation_mode ?? 0,
71172 disabled_members: request.body.disabled_members ?? [],
72- chat_metadata: request.body.chat_metadata ?? {},
73173 fav: request.body.fav,
74174 chat_id: request.body.chat_id ?? id,
75175 chats: request.body.chats ?? [id],
@@ -92,6 +192,7 @@ router.post('/edit', getFileNameValidationFunction('id'), (request, response) =>
92192 if (!request.body || !request.body.id) {
93193 return response.sendStatus(400);
94194 }
195+ warnOnGroupMetadata(request.body);
95196 const id = request.body.id;
96197 const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`));
97198 const fileData = JSON.stringify(request.body, null, 4);
src/server-main.js+2 -0
@@ -68,6 +68,7 @@ import { init as settingsInit } from './endpoints/settings.js';
6868import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
6969import { diskCache } from './endpoints/characters.js';
7070import { migrateFlatSecrets } from './endpoints/secrets.js';
71+import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js';
7172
7273// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
7374// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -265,6 +266,7 @@ async function preSetupTasks() {
265266 console.log();
266267
267268 const directories = await getUserDirectoriesList();
269+ await migrateGroupChatsMetadataFormat(directories);
268270 await checkForNewContent(directories);
269271 await ensureThumbnailCache(directories);
270272 await diskCache.verify(directories);