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, +536 -162Showing whitespace changes
public/css/rm-groups.css+8 -0
@@ -213,6 +213,14 @@
213}213}
214214
215.group_select.avatar {215.group_select.avatar {
216 padding: 0;
217}
218
219.group_select.missing-avatar.inline_avatar {
220 justify-content: center;
221}
222
223.group_select .avatar {
216 flex: 0;224 flex: 0;
217}225}
218226
public/global.d.ts+37 -0
@@ -15,6 +15,42 @@ declare global {
15 type ChatCompletionSettings = typeof oai_settings;15 type ChatCompletionSettings = typeof oai_settings;
16 type TextCompletionSettings = typeof textgenerationwebui_settings;16 type TextCompletionSettings = typeof textgenerationwebui_settings;
17 type MessageTimestamp = string | number | Date;17 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
19 interface ChatMessage {55 interface ChatMessage {
20 name?: string;56 name?: string;
@@ -34,6 +70,7 @@ declare global {
34 };70 };
3571
36 interface ChatMessageExtra {72 interface ChatMessageExtra {
73 gen_id?: number;
37 bias?: string;74 bias?: string;
38 uses_system_ui?: boolean;75 uses_system_ui?: boolean;
39 memory?: string;76 memory?: string;
public/index.html+2 -3
@@ -7215,9 +7215,8 @@
7215 <div title="Move down" data-action="down" class="right_menu_button fa-solid fa-chevron-down" data-i18n="[title]Move down"></div>7215 <div title="Move down" data-action="down" class="right_menu_button fa-solid fa-chevron-down" data-i18n="[title]Move down"></div>
7216 </div>7216 </div>
7217 <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>7217 <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>
7218 <div title="Remove from group" data-action="remove" class="right_menu_button fa-solid fa-2xl fa-xmark" data-i18n="[title]Remove from group">7218 <div title="Remove from group" data-action="remove" class="right_menu_button fa-solid fa-xl 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>
7221 </div>7220 </div>
7222 </div>7221 </div>
7223 </div>7222 </div>
public/script.js+17 -32
@@ -65,7 +65,6 @@ import {
65 renameGroupMember,65 renameGroupMember,
66 createNewGroupChat,66 createNewGroupChat,
67 getGroupAvatar,67 getGroupAvatar,
68 editGroup,
69 deleteGroupChat,68 deleteGroupChat,
70 renameGroupChat,69 renameGroupChat,
71 importGroupChat,70 importGroupChat,
@@ -377,14 +376,13 @@ export let isSwipingAllowed = true; //false when a swipe is in progress, or swip
377let chatSaveTimeout;376let chatSaveTimeout;
378let importFlashTimeout;377let importFlashTimeout;
379export let isChatSaving = false;378export let isChatSaving = false;
380let chat_create_date = '';
381let firstRun = false;379let firstRun = false;
382let settingsReady = false;380let settingsReady = false;
383let currentVersion = '0.0.0';381let currentVersion = '0.0.0';
384export let displayVersion = 'SillyTavern';382export let displayVersion = 'SillyTavern';
385383
386let generation_started = new Date();384let generation_started = new Date();
387/** @type {import('./scripts/char-data.js').v1CharData[]} */385/** @type {Character[]} */
388export let characters = [];386export let characters = [];
389/**387/**
390 * Stringified index of a currently chosen entity in the characters array.388 * Stringified index of a currently chosen entity in the characters array.
@@ -411,6 +409,7 @@ export const chatElement = $('#chat');
411409
412let dialogueResolve = null;410let dialogueResolve = null;
413let dialogueCloseStop = false;411let dialogueCloseStop = false;
412/** @type {ChatMetadata} */
414export let chat_metadata = {};413export let chat_metadata = {};
415/** @type {StreamingProcessor} */414/** @type {StreamingProcessor} */
416export let streamingProcessor = null;415export let streamingProcessor = null;
@@ -1295,7 +1294,7 @@ export async function deleteCharacterChatByName(characterId, fileName) {
1295 // Make sure all the data is loaded.1294 // Make sure all the data is loaded.
1296 await unshallowCharacter(characterId);1295 await unshallowCharacter(characterId);
12971296
1298 /** @type {import('./scripts/char-data.js').v1CharData} */1297 /** @type {Character} */
1299 const character = characters[characterId];1298 const character = characters[characterId];
1300 if (!character) {1299 if (!character) {
1301 console.warn(`Character with ID ${characterId} not found.`);1300 console.warn(`Character with ID ${characterId} not found.`);
@@ -6731,7 +6730,7 @@ export async function renameCharacter(name = null, { silent = false, renameChats
6731 }6730 }
67326731
6733 // Also rename as a group member6732 // Also rename as a group member
6734 await renameGroupMember(oldAvatar, newAvatar, newValue);6733 await renameGroupMember(oldAvatar, newAvatar, newValue.toString());
6735 const renamePastChatsConfirm = renameChats !== null6734 const renamePastChatsConfirm = renameChats !== null
6736 ? renameChats6735 ? renameChats
6737 : silent6736 : silent
@@ -6860,6 +6859,11 @@ export function saveChatDebounced() {
6860 * @returns {Promise<void>}6859 * @returns {Promise<void>}
6861 */6860 */
6862export async function saveChat({ chatName, withMetadata, mesId, force = false } = {}) {6861export 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
6863 if (arguments.length > 0 && typeof arguments[0] !== 'object') {6867 if (arguments.length > 0 && typeof arguments[0] !== 'object') {
6864 console.trace('saveChat called with positional arguments. Please use an object instead.');6868 console.trace('saveChat called with positional arguments. Please use an object instead.');
6865 [chatName, withMetadata, mesId, force] = arguments;6869 [chatName, withMetadata, mesId, force] = arguments;
@@ -6879,26 +6883,15 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
6879 }6883 }
68806884
6881 characters[this_chid]['date_last_chat'] = Date.now();6885 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
6889 const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)6887 const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
6890 ? chat.slice(0, Number(mesId) + 1)6888 ? chat.slice(0, Number(mesId) + 1)
6891 : chat.slice();6889 : chat.slice();
68926890
6893 const chatToSave = [6891 /** @type {ChatHeader} */
6894 {6892 const chatHeader = {
6895 user_name: name1,
6896 character_name: name2,
6897 create_date: chat_create_date,
6898 chat_metadata: metadata,6893 chat_metadata: metadata,
6899 },6894 };
6900 ...trimmedChat,
6901 ];
69026895
6903 try {6896 try {
6904 const result = await fetch('/api/chats/save', {6897 const result = await fetch('/api/chats/save', {
@@ -6908,7 +6901,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
6908 body: JSON.stringify({6901 body: JSON.stringify({
6909 ch_name: characters[this_chid].name,6902 ch_name: characters[this_chid].name,
6910 file_name: fileName,6903 file_name: fileName,
6911 chat: chatToSave,6904 chat: [chatHeader, ...trimmedChat],
6912 avatar_url: characters[this_chid].avatar,6905 avatar_url: characters[this_chid].avatar,
6913 force: force,6906 force: force,
6914 }),6907 }),
@@ -7082,7 +7075,7 @@ export async function unshallowCharacter(characterId) {
7082 return;7075 return;
7083 }7076 }
70847077
7085 /** @type {import('./scripts/char-data.js').v1CharData} */7078 /** @type {Character} */
7086 const character = characters[characterId];7079 const character = characters[characterId];
7087 if (!character) {7080 if (!character) {
7088 console.debug('Character not found:', characterId);7081 console.debug('Character not found:', characterId);
@@ -7121,13 +7114,10 @@ export async function getChat() {
7121 });7114 });
7122 if (response[0] !== undefined) {7115 if (response[0] !== undefined) {
7123 chat.splice(0, chat.length, ...response);7116 chat.splice(0, chat.length, ...response);
7124 chat_create_date = chat[0]['create_date'];
7125 chat_metadata = chat[0]['chat_metadata'] ?? {};7117 chat_metadata = chat[0]['chat_metadata'] ?? {};
71267118
7127 chat.shift();7119 chat.shift();
7128 chat.forEach(ensureMessageMediaIsArray);7120 chat.forEach(ensureMessageMediaIsArray);
7129 } else {
7130 chat_create_date = humanizedDateTime();
7131 }7121 }
7132 if (!chat_metadata['integrity']) {7122 if (!chat_metadata['integrity']) {
7133 chat_metadata['integrity'] = uuidv4();7123 chat_metadata['integrity'] = uuidv4();
@@ -8720,12 +8710,7 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
8720}8710}
87218711
8722export async function saveMetadata() {8712export 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 }
8729}8714}
87308715
8731export async function saveChatConditional() {8716export async function saveChatConditional() {
@@ -10356,7 +10341,7 @@ jQuery(async function () {
10356 });10341 });
10357 $('#rm_button_selected_ch').on('click', function () {10342 $('#rm_button_selected_ch').on('click', function () {
10358 if (selected_group) {10343 if (selected_group) {
10359 select_group_chats(selected_group);10344 select_group_chats(selected_group, false);
10360 } else {10345 } else {
10361 selected_button = 'character_edit';10346 selected_button = 'character_edit';
10362 select_selected_character(this_chid);10347 select_selected_character(this_chid);
@@ -11289,7 +11274,7 @@ jQuery(async function () {
1128911274
11290 $('#rm_button_group_chats').on('click', function () {11275 $('#rm_button_group_chats').on('click', function () {
11291 selected_button = 'group_chats';11276 selected_button = 'group_chats';
11292 select_group_chats();11277 select_group_chats(null, false);
11293 });11278 });
1129411279
11295 $('#rm_button_back_from_group').on('click', function () {11280 $('#rm_button_back_from_group').on('click', function () {
public/scripts/bookmarks.js+21 -20
@@ -11,6 +11,7 @@ import {
11 chat,11 chat,
12 saveChatConditional,12 saveChatConditional,
13 saveItemizedPrompts,13 saveItemizedPrompts,
14 setActiveGroup,
14} from '../script.js';15} from '../script.js';
15import { humanizedDateTime } from './RossAscends-mods.js';16import { humanizedDateTime } from './RossAscends-mods.js';
16import {17import {
@@ -298,28 +299,31 @@ export async function convertSoloToGroupChat() {
298 const chats = [chatName];299 const chats = [chatName];
299 const members = [character.avatar];300 const members = [character.avatar];
300 const favChecked = character.fav || character.fav == 'true';301 const favChecked = character.fav || character.fav == 'true';
301 /** @type {any} */302 /** @type {ChatMetadata} */
302 const metadata = Object.assign({}, chat_metadata);303 const metadata = Object.assign({}, chat_metadata);
303 delete metadata.main_chat;304 delete metadata.main_chat;
304305 /** @type {ChatHeader} */
305 const createGroupResponse = await fetch('/api/groups/create', {306 const chatHeader = { chat_metadata: metadata };
306 method: 'POST',307 /** @type {Omit<Group, 'id'>} */
307 headers: getRequestHeaders(),308 const groupCreateModel = {
308 body: JSON.stringify({
309 name: name,309 name: name,
310 members: members,310 members: members,
311 avatar_url: avatar,311 avatar_url: avatar,
312 allow_self_responses: false,312 allow_self_responses: false,
313 activation_strategy: group_activation_strategy.NATURAL,313 activation_strategy: group_activation_strategy.NATURAL,
314 disabled_members: [],314 disabled_members: [],
315 chat_metadata: metadata,
316 fav: favChecked,315 fav: favChecked,
317 chat_id: chatName,316 chat_id: chatName,
318 chats: chats,317 chats: chats,
319 hideMutedSprites: false,318 hideMutedSprites: false,
320 generation_mode: group_generation_mode.SWAP,319 generation_mode: group_generation_mode.SWAP,
321 auto_mode_delay: DEFAULT_AUTO_MODE_DELAY,320 auto_mode_delay: DEFAULT_AUTO_MODE_DELAY,
322 }),321 };
322
323 const createGroupResponse = await fetch('/api/groups/create', {
324 method: 'POST',
325 headers: getRequestHeaders(),
326 body: JSON.stringify(groupCreateModel),
323 });327 });
324328
325 if (!createGroupResponse.ok) {329 if (!createGroupResponse.ok) {
@@ -327,6 +331,7 @@ export async function convertSoloToGroupChat() {
327 return;331 return;
328 }332 }
329333
334 /** @type {Group} */
330 const group = await createGroupResponse.json();335 const group = await createGroupResponse.json();
331336
332 // Convert tags list and assign to group337 // Convert tags list and assign to group
@@ -336,39 +341,34 @@ export async function convertSoloToGroupChat() {
336 await getCharacters();341 await getCharacters();
337342
338 // Convert chat to group format343 // Convert chat to group format
339 const groupChat = chat.slice();344 const groupChat = [...chat].map(m => structuredClone(m));
340 const genIdFirst = Date.now();345 const genIdFirst = Date.now();
341346
342 for (let index = 0; index < groupChat.length; index++) {347 for (let index = 0; index < groupChat.length; index++) {
343 const message = groupChat[index];348 const message = groupChat[index];
344349
345 // Save group-chat marker
346 if (index == 0) {
347 // @ts-ignore
348 message.is_group = true;
349 }
350
351 // Skip messages we don't care about350 // Skip messages we don't care about
352 if (message.is_user || message.is_system || message.extra?.type === system_message_types.NARRATOR || message.force_avatar !== undefined) {351 if (message.is_user || message.is_system || message.extra?.type === system_message_types.NARRATOR || message.force_avatar !== undefined) {
353 continue;352 continue;
354 }353 }
355354
355 if (!message.extra || typeof message.extra !== 'object') {
356 message.extra = {};
357 }
358
356 // Set force fields for solo character359 // Set force fields for solo character
357 message.name = character.name;360 message.name = character.name;
358 message.original_avatar = character.avatar;361 message.original_avatar = character.avatar;
359 message.force_avatar = getThumbnailUrl('avatar', character.avatar);362 message.force_avatar = getThumbnailUrl('avatar', character.avatar);
360
361 // Allow regens of a single message in group363 // 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 }
365 }365 }
366366
367 // Save group chat367 // Save group chat
368 const createChatResponse = await fetch('/api/chats/group/save', {368 const createChatResponse = await fetch('/api/chats/group/save', {
369 method: 'POST',369 method: 'POST',
370 headers: getRequestHeaders(),370 headers: getRequestHeaders(),
371 body: JSON.stringify({ id: chatName, chat: groupChat }),371 body: JSON.stringify({ id: chatName, chat: [chatHeader, ...groupChat] }),
372 });372 });
373373
374 if (!createChatResponse.ok) {374 if (!createChatResponse.ok) {
@@ -378,6 +378,7 @@ export async function convertSoloToGroupChat() {
378 }378 }
379379
380 // Click on the freshly selected group to open it380 // Click on the freshly selected group to open it
381 setActiveGroup(group.id);
381 await openGroupById(group.id);382 await openGroupById(group.id);
382383
383 toastr.success(t`The chat has been successfully converted!`);384 toastr.success(t`The chat has been successfully converted!`);
public/scripts/extensions/attachments/index.js+1 -1
@@ -218,7 +218,7 @@ function cleanUpAttachments() {
218218
219/**219/**
220 * Clean up character attachments when a character is deleted.220 * Clean up character attachments when a character is deleted.
221 * @param {{character: import('../../char-data.js').v1CharData}} data Event data221 * @param {{character: Character}} data Event data
222 */222 */
223function cleanUpCharacterAttachments(data) {223function cleanUpCharacterAttachments(data) {
224 const avatar = data?.character?.avatar;224 const avatar = data?.character?.avatar;
public/scripts/extensions/gallery/index.js+1 -1
@@ -95,7 +95,7 @@ function initSettings() {
9595
96/**96/**
97 * Retrieves the gallery folder for a given character.97 * Retrieves the gallery folder for a given character.
98 * @param {import('../../char-data.js').v1CharData} char Character data98 * @param {Character} char Character data
99 * @returns {string} The gallery folder for the character99 * @returns {string} The gallery folder for the character
100 */100 */
101function getGalleryFolder(char) {101function getGalleryFolder(char) {
public/scripts/extensions/quick-reply/index.js+1 -1
@@ -152,7 +152,7 @@ const handleCharChange = () => {
152 lastCharId = this_chid;152 lastCharId = this_chid;
153153
154 // If no character is loaded, there's nothing more to do.154 // If no character is loaded, there's nothing more to do.
155 /** @type {import('../../char-data.js').v1CharData} */155 /** @type {Character} */
156 const character = characters[this_chid];156 const character = characters[this_chid];
157 if (!character || selected_group) {157 if (!character || selected_group) {
158 return;158 return;
public/scripts/extensions/regex/engine.js+3 -3
@@ -105,7 +105,7 @@ export async function saveScriptsByType(scripts, scriptType) {
105105
106/**106/**
107 * Check if character's regexes are allowed to be used; if character is undefined, returns false107 * Check if character's regexes are allowed to be used; if character is undefined, returns false
108 * @param {import('../../char-data.js').v1CharData|undefined} character108 * @param {Character|undefined} character
109 * @returns {boolean}109 * @returns {boolean}
110 */110 */
111export function isScopedScriptsAllowed(character) {111export function isScopedScriptsAllowed(character) {
@@ -114,7 +114,7 @@ export function isScopedScriptsAllowed(character) {
114114
115/**115/**
116 * Allow character's regexes to be used; if character is undefined, do nothing116 * Allow character's regexes to be used; if character is undefined, do nothing
117 * @param {import('../../char-data.js').v1CharData|undefined} character117 * @param {Character|undefined} character
118 * @returns {void}118 * @returns {void}
119 */119 */
120export function allowScopedScripts(character) {120export function allowScopedScripts(character) {
@@ -133,7 +133,7 @@ export function allowScopedScripts(character) {
133133
134/**134/**
135 * Disallow character's regexes to be used; if character is undefined, do nothing135 * Disallow character's regexes to be used; if character is undefined, do nothing
136 * @param {import('../../char-data.js').v1CharData|undefined} character136 * @param {Character|undefined} character
137 * @returns {void}137 * @returns {void}
138 */138 */
139export function disallowScopedScripts(character) {139export function disallowScopedScripts(character) {
public/scripts/group-chats.js+295 -75
@@ -17,6 +17,8 @@ import {
17 renderPaginationDropdown,17 renderPaginationDropdown,
18 paginationDropdownChangeHandler,18 paginationDropdownChangeHandler,
19 waitUntilCondition,19 waitUntilCondition,
20 uuidv4,
21 humanFileSize,
20} from './utils.js';22} from './utils.js';
21import { RA_CountCharTokens, humanizedDateTime, dragElement, favsToHotswap, getMessageTimeStamp } from './RossAscends-mods.js';23import { RA_CountCharTokens, humanizedDateTime, dragElement, favsToHotswap, getMessageTimeStamp } from './RossAscends-mods.js';
22import { power_user, loadMovingUIState, sortEntitiesList } from './power-user.js';24import { power_user, loadMovingUIState, sortEntitiesList } from './power-user.js';
@@ -109,7 +111,9 @@ export {
109let is_group_generating = false; // Group generation flag111let is_group_generating = false; // Group generation flag
110let is_group_automode_enabled = false;112let is_group_automode_enabled = false;
111let hideMutedSprites = false;113let hideMutedSprites = false;
114/** @type {Group[]} */
112let groups = [];115let groups = [];
116/** @type {string|null} */
113let selected_group = null;117let selected_group = null;
114let group_generation_id = null;118let group_generation_id = null;
115let fav_grp_checked = false;119let fav_grp_checked = false;
@@ -143,6 +147,11 @@ function setAutoModeWorker() {
143 autoModeWorker = setInterval(groupChatAutoModeWorker, autoModeDelay * 1000);147 autoModeWorker = setInterval(groupChatAutoModeWorker, autoModeDelay * 1000);
144}148}
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 */
146async function _save(group, reload = true) {155async function _save(group, reload = true) {
147 await fetch('/api/groups/edit', {156 await fetch('/api/groups/edit', {
148 method: 'POST',157 method: 'POST',
@@ -179,6 +188,11 @@ async function regenerateGroup() {
179 generateGroupWrapper(false, 'normal', { signal: abortController.signal });188 generateGroupWrapper(false, 'normal', { signal: abortController.signal });
180}189}
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 */
182async function loadGroupChat(chatId) {196async function loadGroupChat(chatId) {
183 const response = await fetch('/api/chats/group/get', {197 const response = await fetch('/api/chats/group/get', {
184 method: 'POST',198 method: 'POST',
@@ -188,12 +202,20 @@ async function loadGroupChat(chatId) {
188202
189 if (response.ok) {203 if (response.ok) {
190 const data = await response.json();204 const data = await response.json();
205 if (!Array.isArray(data)) {
206 return [];
207 }
191 return data;208 return data;
192 }209 }
193210
194 return [];211 return [];
195}212}
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 */
197async function validateGroup(group) {219async function validateGroup(group) {
198 if (!group) return;220 if (!group) return;
199221
@@ -225,6 +247,12 @@ async function validateGroup(group) {
225 }247 }
226}248}
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 */
228export async function getGroupChat(groupId, reload = false) {256export async function getGroupChat(groupId, reload = false) {
229 const group = groups.find((x) => x.id === groupId);257 const group = groups.find((x) => x.id === groupId);
230 if (!group) {258 if (!group) {
@@ -238,9 +266,19 @@ export async function getGroupChat(groupId, reload = false) {
238266
239 const chat_id = group.chat_id;267 const chat_id = group.chat_id;
240 const data = await loadGroupChat(chat_id);268 const data = await loadGroupChat(chat_id);
241 const metadata = group.chat_metadata ?? {};269 const metadata = data?.[0]?.chat_metadata ?? {};
242 const freshChat = !metadata.tainted && (!Array.isArray(data) || !data.length);270 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
244 await loadItemizedPrompts(getCurrentChatId());282 await loadItemizedPrompts(getCurrentChatId());
245283
246 if (group && Array.isArray(group.members) && freshChat) {284 if (group && Array.isArray(group.members) && freshChat) {
@@ -266,7 +304,6 @@ export async function getGroupChat(groupId, reload = false) {
266 }304 }
267 await saveGroupChat(groupId, false);305 await saveGroupChat(groupId, false);
268 } else if (Array.isArray(data) && data.length) {306 } else if (Array.isArray(data) && data.length) {
269 data[0].is_group = true;
270 chat.splice(0, chat.length, ...data);307 chat.splice(0, chat.length, ...data);
271 chat.forEach(ensureMessageMediaIsArray);308 chat.forEach(ensureMessageMediaIsArray);
272 chatElement.find('.mes').remove();309 chatElement.find('.mes').remove();
@@ -528,6 +565,11 @@ export function getGroupCharacterCards(groupId, characterId) {
528 return { description, personality, scenario, mesExamples };565 return { description, personality, scenario, mesExamples };
529}566}
530567
568/**
569 * Gets the first message for a character.
570 * @param {Character} character Character object
571 * @returns {Promise<ChatMessage>} First message object
572 */
531async function getFirstCharacterMessage(character) {573async function getFirstCharacterMessage(character) {
532 let messageText = character.first_mes;574 let messageText = character.first_mes;
533575
@@ -566,27 +608,66 @@ function resetSelectedGroup() {
566 is_group_generating = false;608 is_group_generating = false;
567}609}
568610
569async 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 */
618async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
570 const group = groups.find(x => x.id == groupId);619 const group = groups.find(x => x.id == groupId);
571 const chat_id = group.chat_id;620 const chat_id = group.chat_id;
572 group['date_last_chat'] = Date.now();621 group['date_last_chat'] = Date.now();
622 /** @type {ChatHeader} */
623 const chatHeader = {
624 chat_metadata: { ...chat_metadata },
625 };
573 const response = await fetch('/api/chats/group/save', {626 const response = await fetch('/api/chats/group/save', {
574 method: 'POST',627 method: 'POST',
575 headers: getRequestHeaders(),628 headers: getRequestHeaders(),
576 body: JSON.stringify({ id: chat_id, chat: [...chat] }),629 body: JSON.stringify({ id: chat_id, chat: [chatHeader, ...chat], force: force }),
577 });630 });
578631
579 if (!response.ok) {632 if (!response.ok) {
633 const errorData = await response.json();
634 const isIntegrityError = errorData?.error === 'integrity' && !force;
635 if (!isIntegrityError) {
580 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group Chat could not be saved`);636 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group Chat could not be saved`);
581 console.error('Group chat could not be saved', response);637 console.error('Group chat could not be saved', response);
582 return;638 return;
583 }639 }
584640
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);
658 }
659
585 if (shouldSaveGroup) {660 if (shouldSaveGroup) {
586 await editGroup(groupId, false, false);661 await editGroup(groupId, false, false);
587 }662 }
588}663}
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 */
590export async function renameGroupMember(oldAvatar, newAvatar, newName) {671export async function renameGroupMember(oldAvatar, newAvatar, newName) {
591 // Scan every group for our renamed character672 // Scan every group for our renamed character
592 for (const group of groups) {673 for (const group of groups) {
@@ -614,6 +695,11 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) {
614 if (Array.isArray(messages) && messages.length) {695 if (Array.isArray(messages) && messages.length) {
615 // Iterate over every chat message696 // Iterate over every chat message
616 for (const message of messages) {697 for (const message of messages) {
698 // Skip the chat header
699 if (Object.hasOwn(message, 'chat_metadata')) {
700 continue;
701 }
702
617 // Only look at character messages703 // Only look at character messages
618 if (message.is_user || message.is_system) {704 if (message.is_user || message.is_system) {
619 continue;705 continue;
@@ -652,6 +738,9 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) {
652 }738 }
653}739}
654740
741/**
742 * Fetches all groups from the server and processes them.
743 */
655async function getGroups() {744async function getGroups() {
656 const response = await fetch('/api/groups/all', {745 const response = await fetch('/api/groups/all', {
657 method: 'POST',746 method: 'POST',
@@ -659,8 +748,9 @@ async function getGroups() {
659 });748 });
660749
661 if (response.ok) {750 if (response.ok) {
751 /** @type {Group[]} */
662 const data = await response.json();752 const data = await response.json();
663 groups = data.sort((a, b) => a.id - b.id);753 groups = data.slice();
664754
665 // Convert groups to new format755 // Convert groups to new format
666 for (const group of groups) {756 for (const group of groups) {
@@ -678,9 +768,6 @@ async function getGroups() {
678 .filter(x => x)768 .filter(x => x)
679 .filter(onlyUnique);769 .filter(onlyUnique);
680 }770 }
681 if (group.past_metadata == undefined) {
682 group.past_metadata = {};
683 }
684 if (typeof group.chat_id === 'number') {771 if (typeof group.chat_id === 'number') {
685 group.chat_id = String(group.chat_id);772 group.chat_id = String(group.chat_id);
686 }773 }
@@ -691,6 +778,11 @@ async function getGroups() {
691 }778 }
692}779}
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 */
694export function getGroupBlock(group) {786export function getGroupBlock(group) {
695 let count = 0;787 let count = 0;
696 let namesList = [];788 let namesList = [];
@@ -712,7 +804,7 @@ export function getGroupBlock(group) {
712 template.find('.ch_name').text(group.name).attr('title', `[Group] ${group.name}`);804 template.find('.ch_name').text(group.name).attr('title', `[Group] ${group.name}`);
713 template.find('.group_fav_icon').css('display', 'none');805 template.find('.group_fav_icon').css('display', 'none');
714 template.addClass(group.fav ? 'is_fav' : '');806 template.addClass(group.fav ? 'is_fav' : '');
715 template.find('.ch_fav').val(group.fav);807 template.find('.ch_fav').val(String(group.fav));
716 template.find('.group_select_counter').text(count + ' ' + (count != 1 ? t`characters` : t`character`));808 template.find('.group_select_counter').text(count + ' ' + (count != 1 ? t`characters` : t`character`));
717 template.find('.group_select_block_list').text(namesList.join(', '));809 template.find('.group_select_block_list').text(namesList.join(', '));
718810
@@ -728,6 +820,10 @@ export function getGroupBlock(group) {
728 return template;820 return template;
729}821}
730822
823/**
824 * Updates the avatar display for a given group.
825 * @param {Group} group Group object
826 */
731function updateGroupAvatar(group) {827function updateGroupAvatar(group) {
732 $('#group_avatar_preview').empty().append(getGroupAvatar(group));828 $('#group_avatar_preview').empty().append(getGroupAvatar(group));
733829
@@ -740,7 +836,11 @@ function updateGroupAvatar(group) {
740 favsToHotswap();836 favsToHotswap();
741}837}
742838
743// check if isDataURLor if it's a valid local file url839/**
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 */
744function isValidImageUrl(url) {844function isValidImageUrl(url) {
745 // check if empty dict845 // check if empty dict
746 if (Object.keys(url).length === 0) {846 if (Object.keys(url).length === 0) {
@@ -749,6 +849,11 @@ function isValidImageUrl(url) {
749 return isDataURL(url) || (url && (url.startsWith('user') || url.startsWith('/user')));849 return isDataURL(url) || (url && (url.startsWith('user') || url.startsWith('/user')));
750}850}
751851
852/**
853 * Gets a group avatar element.
854 * @param {Group} group Group object
855 * @returns {JQuery<HTMLElement>} Group avatar element
856 */
752function getGroupAvatar(group) {857function getGroupAvatar(group) {
753 if (!group) {858 if (!group) {
754 return $(`<div class="avatar"><img src="${default_avatar}"></div>`);859 return $(`<div class="avatar"><img src="${default_avatar}"></div>`);
@@ -797,6 +902,11 @@ function getGroupAvatar(group) {
797 return groupAvatar;902 return groupAvatar;
798}903}
799904
905/**
906 * Gets chat IDs for a group.
907 * @param {string} groupId Group ID
908 * @returns {string[]} Array of chat IDs
909 */
800function getGroupChatNames(groupId) {910function getGroupChatNames(groupId) {
801 const group = groups.find(x => x.id === groupId);911 const group = groups.find(x => x.id === groupId);
802912
@@ -811,7 +921,14 @@ function getGroupChatNames(groupId) {
811 return names;921 return names;
812}922}
813923
814async 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 */
931async function generateGroupWrapper(byAutoMode, type = null, params = {}) {
815 function throwIfAborted() {932 function throwIfAborted() {
816 if (params.signal instanceof AbortSignal && params.signal.aborted) {933 if (params.signal instanceof AbortSignal && params.signal.aborted) {
817 throw new Error('AbortSignal was fired. Group generation stopped');934 throw new Error('AbortSignal was fired. Group generation stopped');
@@ -830,7 +947,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
830947
831 // Auto-navigate back to group menu948 // Auto-navigate back to group menu
832 if (menu_type !== 'group_edit') {949 if (menu_type !== 'group_edit') {
833 select_group_chats(selected_group);950 select_group_chats(selected_group, false);
834 await delay(1);951 await delay(1);
835 }952 }
836953
@@ -859,7 +976,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
859 let activationText = '';976 let activationText = '';
860 let isUserInput = false;977 let isUserInput = false;
861978
862 if (userInput?.length && !by_auto_mode) {979 if (userInput?.length && !byAutoMode) {
863 isUserInput = true;980 isUserInput = true;
864 activationText = userInput;981 activationText = userInput;
865 } else {982 } else {
@@ -935,12 +1052,12 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
9351052
936 // Wait for generation to finish1053 // Wait for generation to finish
937 const generateType = ['swipe', 'impersonate', 'quiet', 'continue'].includes(type) ? type : 'normal';1054 const generateType = ['swipe', 'impersonate', 'quiet', 'continue'].includes(type) ? type : 'normal';
938 textResult = await Generate(generateType, { automatic_trigger: by_auto_mode, ...(params || {}) });1055 textResult = await Generate(generateType, { automatic_trigger: byAutoMode, ...(params || {}) });
939 let messageChunk = textResult?.messageChunk;1056 let messageChunk = textResult?.messageChunk;
9401057
941 if (messageChunk) {1058 if (messageChunk) {
942 while (shouldAutoContinue(messageChunk, type === 'impersonate')) {1059 while (shouldAutoContinue(messageChunk, type === 'impersonate')) {
943 textResult = await Generate('continue', { automatic_trigger: by_auto_mode, ...(params || {}) });1060 textResult = await Generate('continue', { automatic_trigger: byAutoMode, ...(params || {}) });
944 messageChunk = textResult?.messageChunk;1061 messageChunk = textResult?.messageChunk;
945 }1062 }
946 }1063 }
@@ -966,6 +1083,10 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
966 return Promise.resolve(textResult);1083 return Promise.resolve(textResult);
967}1084}
9681085
1086/**
1087 * Gets the generation ID of the last chat message.
1088 * @returns {number|null} Generation ID or null
1089 */
969function getLastMessageGenerationId() {1090function getLastMessageGenerationId() {
970 let generationId = null;1091 let generationId = null;
971 if (chat.length > 0) {1092 if (chat.length > 0) {
@@ -977,6 +1098,11 @@ function getLastMessageGenerationId() {
977 return generationId;1098 return generationId;
978}1099}
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 */
980function activateImpersonate(members) {1106function activateImpersonate(members) {
981 const randomIndex = Math.floor(Math.random() * members.length);1107 const randomIndex = Math.floor(Math.random() * members.length);
982 const activatedMembers = [members[randomIndex]];1108 const activatedMembers = [members[randomIndex]];
@@ -1039,6 +1165,11 @@ function activateSwipe(members, { allowSystem = false } = {}) {
1039 return memberIds;1165 return memberIds;
1040}1166}
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 */
1042function activateListOrder(members) {1173function activateListOrder(members) {
1043 let activatedMembers = members.filter(onlyUnique);1174 let activatedMembers = members.filter(onlyUnique);
10441175
@@ -1092,6 +1223,15 @@ function activatePooledOrder(members, lastMessage, isUserInput) {
1092 return memberId !== -1 ? [memberId] : [];1223 return memberId !== -1 ? [memberId] : [];
1093}1224}
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 */
1095function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, isUserInput) {1235function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, isUserInput) {
1096 let activatedMembers = [];1236 let activatedMembers = [];
10971237
@@ -1168,6 +1308,11 @@ function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, i
1168 return memberIds;1308 return memberIds;
1169}1309}
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 */
1171async function deleteGroup(id) {1316async function deleteGroup(id) {
1172 const group = groups.find((x) => x.id === id);1317 const group = groups.find((x) => x.id === id);
11731318
@@ -1197,6 +1342,13 @@ async function deleteGroup(id) {
1197 }1342 }
1198}1343}
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 */
1200export async function editGroup(id, immediately, reload = true) {1352export async function editGroup(id, immediately, reload = true) {
1201 let group = groups.find((x) => x.id === id);1353 let group = groups.find((x) => x.id === id);
12021354
@@ -1204,11 +1356,6 @@ export async function editGroup(id, immediately, reload = true) {
1204 return;1356 return;
1205 }1357 }
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
1212 if (immediately) {1359 if (immediately) {
1213 return await _save(group, reload);1360 return await _save(group, reload);
1214 }1361 }
@@ -1260,6 +1407,12 @@ async function groupChatAutoModeWorker() {
1260 await generateGroupWrapper(true, 'auto', { signal: groupAutoModeAbortController.signal });1407 await generateGroupWrapper(true, 'auto', { signal: groupAutoModeAbortController.signal });
1261}1408}
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 */
1263async function modifyGroupMember(groupId, groupMember, isDelete) {1416async function modifyGroupMember(groupId, groupMember, isDelete) {
1264 const id = groupMember.data('id');1417 const id = groupMember.data('id');
1265 const thisGroup = groups.find((x) => x.id == groupId);1418 const thisGroup = groups.find((x) => x.id == groupId);
@@ -1287,9 +1440,16 @@ async function modifyGroupMember(groupId, groupMember, isDelete) {
1287 $('#rm_group_submit').prop('disabled', !groupHasMembers);1440 $('#rm_group_submit').prop('disabled', !groupHasMembers);
1288}1441}
12891442
1290async 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 */
1450async function reorderGroupMember(groupId, groupMember, direction) {
1291 const id = groupMember.data('id');1451 const id = groupMember.data('id');
1292 const thisGroup = groups.find((x) => x.id == chat_id);1452 const thisGroup = groups.find((x) => x.id == groupId);
1293 const memberArray = thisGroup?.members ?? newGroupMembers;1453 const memberArray = thisGroup?.members ?? newGroupMembers;
12941454
1295 const indexOf = memberArray.indexOf(id);1455 const indexOf = memberArray.indexOf(id);
@@ -1312,7 +1472,7 @@ async function reorderGroupMember(chat_id, groupMember, direction) {
13121472
1313 // Existing groups need to modify members list1473 // Existing groups need to modify members list
1314 if (openGroupId) {1474 if (openGroupId) {
1315 await editGroup(chat_id, false, false);1475 await editGroup(groupId, false, false);
1316 updateGroupAvatar(thisGroup);1476 updateGroupAvatar(thisGroup);
1317 }1477 }
1318}1478}
@@ -1358,10 +1518,16 @@ async function onGroupNameInput() {
1358 let _thisGroup = groups.find((x) => x.id == openGroupId);1518 let _thisGroup = groups.find((x) => x.id == openGroupId);
1359 _thisGroup.name = $(this).val();1519 _thisGroup.name = $(this).val();
1360 $('#rm_button_selected_ch').children('h2').text(_thisGroup.name);1520 $('#rm_button_selected_ch').children('h2').text(_thisGroup.name);
1361 await editGroup(openGroupId);1521 await editGroup(openGroupId, false);
1362 }1522 }
1363}1523}
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 */
1365function isGroupMember(group, avatarId) {1531function isGroupMember(group, avatarId) {
1366 if (group && Array.isArray(group.members)) {1532 if (group && Array.isArray(group.members)) {
1367 return group.members.includes(avatarId);1533 return group.members.includes(avatarId);
@@ -1370,6 +1536,13 @@ function isGroupMember(group, avatarId) {
1370 }1536 }
1371}1537}
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 */
1373function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {1546function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {
1374 function sortMembersFn(a, b) {1547 function sortMembersFn(a, b) {
1375 const membersArray = thisGroup?.members ?? newGroupMembers;1548 const membersArray = thisGroup?.members ?? newGroupMembers;
@@ -1461,15 +1634,20 @@ function printGroupMembers() {
1461 });1634 });
1462}1635}
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 */
1464function getGroupCharacterBlock(character) {1642function getGroupCharacterBlock(character) {
1465 const avatar = getThumbnailUrl('avatar', character.avatar);1643 const avatar = getThumbnailUrl('avatar', character.avatar);
1466 const template = $('#group_member_template .group_member').clone();1644 const template = $('#group_member_template .group_member').clone();
1467 const isFav = character.fav || character.fav == 'true';1645 const isFav = !!character.fav || character.fav == 'true';
1468 template.data('id', character.avatar);1646 template.data('id', character.avatar);
1469 template.find('.avatar img').attr({ 'src': avatar, 'title': character.avatar });1647 template.find('.avatar img').attr({ 'src': avatar, 'title': character.avatar });
1470 template.find('.ch_name').text(character.name);1648 template.find('.ch_name').text(character.name);
1471 template.attr('data-chid', characters.indexOf(character));1649 template.attr('data-chid', characters.indexOf(character));
1472 template.find('.ch_fav').val(isFav);1650 template.find('.ch_fav').val(String(isFav));
1473 template.toggleClass('is_fav', isFav);1651 template.toggleClass('is_fav', isFav);
14741652
1475 const auxFieldName = power_user.aux_field || 'character_version';1653 const auxFieldName = power_user.aux_field || 'character_version';
@@ -1503,6 +1681,11 @@ function getGroupCharacterBlock(character) {
1503 return template;1681 return template;
1504}1682}
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 */
1506function isGroupMemberDisabled(avatarId) {1689function isGroupMemberDisabled(avatarId) {
1507 const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);1690 const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
1508 return Boolean(thisGroup && thisGroup.disabled_members.includes(avatarId));1691 return Boolean(thisGroup && thisGroup.disabled_members.includes(avatarId));
@@ -1553,6 +1736,11 @@ async function onHideMutedSpritesClick(value) {
1553 }1736 }
1554}1737}
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 */
1556function toggleHiddenControls(group, generationMode = null) {1744function toggleHiddenControls(group, generationMode = null) {
1557 const isJoin = [group_generation_mode.APPEND, group_generation_mode.APPEND_DISABLED].includes(generationMode ?? group?.generation_mode);1745 const isJoin = [group_generation_mode.APPEND, group_generation_mode.APPEND_DISABLED].includes(generationMode ?? group?.generation_mode);
1558 $('#rm_group_generation_mode_join_prefix').parent().toggle(isJoin);1746 $('#rm_group_generation_mode_join_prefix').parent().toggle(isJoin);
@@ -1564,6 +1752,11 @@ function toggleHiddenControls(group, generationMode = null) {
1564 }1752 }
1565}1753}
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 */
1567function select_group_chats(groupId, skipAnimation) {1760function select_group_chats(groupId, skipAnimation) {
1568 openGroupId = groupId;1761 openGroupId = groupId;
1569 newGroupMembers = [];1762 newGroupMembers = [];
@@ -1774,6 +1967,11 @@ function updateFavButtonState(state) {
1774 $('#group_favorite_button').toggleClass('fav_off', !fav_grp_checked);1967 $('#group_favorite_button').toggleClass('fav_off', !fav_grp_checked);
1775}1968}
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 */
1777export async function openGroupById(groupId) {1975export async function openGroupById(groupId) {
1778 if (isChatSaving) {1976 if (isChatSaving) {
1779 toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`);1977 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) {
1786 }1984 }
17871985
1788 if (!is_send_press && !is_group_generating) {1986 if (!is_send_press && !is_group_generating) {
1789 select_group_chats(groupId);1987 select_group_chats(groupId, false);
17901988
1791 if (selected_group !== groupId) {1989 if (selected_group !== groupId) {
1792 groupChatQueueOrder = new Map();1990 groupChatQueueOrder = new Map();
@@ -1806,6 +2004,11 @@ export async function openGroupById(groupId) {
1806 return false;2004 return false;
1807}2005}
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 */
1809async function openCharacterDefinition(characterSelect) {2012async function openCharacterDefinition(characterSelect) {
1810 if (is_group_generating) {2013 if (is_group_generating) {
1811 toastr.warning(t`Can't peek a character while group reply is being generated`);2014 toastr.warning(t`Can't peek a character while group reply is being generated`);
@@ -1834,7 +2037,7 @@ function filterGroupMembers() {
1834}2037}
18352038
1836async function createGroup() {2039async function createGroup() {
1837 let name = $('#rm_group_chat_name').val();2040 let name = $('#rm_group_chat_name').val().toString();
1838 let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');2041 let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');
1839 let activationStrategy = Number($('#rm_group_activation_strategy').find(':selected').val()) ?? group_activation_strategy.NATURAL;2042 let activationStrategy = Number($('#rm_group_activation_strategy').find(':selected').val()) ?? group_activation_strategy.NATURAL;
1840 let generationMode = Number($('#rm_group_generation_mode').find(':selected').val()) ?? group_generation_mode.SWAP;2043 let generationMode = Number($('#rm_group_generation_mode').find(':selected').val()) ?? group_generation_mode.SWAP;
@@ -1846,29 +2049,30 @@ async function createGroup() {
1846 name = t`Group: ${memberNames}`;2049 name = t`Group: ${memberNames}`;
1847 }2050 }
18482051
1849 const avatar_url = $('#group_avatar_preview img').attr('src');2052 const avatarUrl = $('#group_avatar_preview img').attr('src');
1850
1851 const chatName = humanizedDateTime();2053 const chatName = humanizedDateTime();
1852 const chats = [chatName];2054 const chats = [chatName];
18532055
1854 const createGroupResponse = await fetch('/api/groups/create', {2056 /** @type {Omit<Group, 'id'>} */
1855 method: 'POST',2057 const groupCreateModel = {
1856 headers: getRequestHeaders(),
1857 body: JSON.stringify({
1858 name: name,2058 name: name,
1859 members: members,2059 members: members,
1860 avatar_url: isValidImageUrl(avatar_url) ? avatar_url : default_avatar,2060 avatar_url: isValidImageUrl(avatarUrl) ? avatarUrl : default_avatar,
1861 allow_self_responses: allowSelfResponses,2061 allow_self_responses: allowSelfResponses,
1862 hideMutedSprites: hideMutedSprites,2062 hideMutedSprites: hideMutedSprites,
1863 activation_strategy: activationStrategy,2063 activation_strategy: activationStrategy,
1864 generation_mode: generationMode,2064 generation_mode: generationMode,
1865 disabled_members: [],2065 disabled_members: [],
1866 chat_metadata: {},
1867 fav: fav_grp_checked,2066 fav: fav_grp_checked,
1868 chat_id: chatName,2067 chat_id: chatName,
1869 chats: chats,2068 chats: chats,
1870 auto_mode_delay: autoModeDelay,2069 auto_mode_delay: autoModeDelay,
1871 }),2070 };
2071
2072 const createGroupResponse = await fetch('/api/groups/create', {
2073 method: 'POST',
2074 headers: getRequestHeaders(),
2075 body: JSON.stringify(groupCreateModel),
1872 });2076 });
18732077
1874 if (createGroupResponse.ok) {2078 if (createGroupResponse.ok) {
@@ -1880,6 +2084,11 @@ async function createGroup() {
1880 }2084 }
1881}2085}
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 */
1883export async function createNewGroupChat(groupId) {2092export async function createNewGroupChat(groupId) {
1884 const group = groups.find(x => x.id === groupId);2093 const group = groups.find(x => x.id === groupId);
18852094
@@ -1887,27 +2096,22 @@ export async function createNewGroupChat(groupId) {
1887 return;2096 return;
1888 }2097 }
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
1897 await clearChat();2099 await clearChat();
1898 chat.length = 0;2100 chat.length = 0;
1899 if (oldChatName) {2101 const newChatName = humanizedDateTime();
1900 group.past_metadata[oldChatName] = Object.assign({}, chat_metadata);
1901 }
1902 group.chats.push(newChatName);2102 group.chats.push(newChatName);
1903 group.chat_id = newChatName;2103 group.chat_id = newChatName;
1904 group.chat_metadata = {};2104 updateChatMetadata({}, true);
1905 updateChatMetadata(group.chat_metadata, true);
19062105
1907 await editGroup(group.id, true, false);2106 await editGroup(group.id, true, false);
1908 await getGroupChat(group.id);2107 await getGroupChat(group.id);
1909}2108}
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 */
1911export async function getGroupPastChats(groupId) {2115export async function getGroupPastChats(groupId) {
1912 const group = groups.find(x => x.id === groupId);2116 const group = groups.find(x => x.id === groupId);
19132117
@@ -1920,16 +2124,22 @@ export async function getGroupPastChats(groupId) {
1920 try {2124 try {
1921 for (const chatId of group.chats) {2125 for (const chatId of group.chats) {
1922 const messages = await loadGroupChat(chatId);2126 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;
1925 const lastMessage = messages.length ? messages[messages.length - 1].mes : '[The chat is empty]';2135 const lastMessage = messages.length ? messages[messages.length - 1].mes : '[The chat is empty]';
1926 const lastMessageDate = messages.length ? (messages[messages.length - 1].send_date || Date.now()) : Date.now();2136 const lastMessageDate = messages.length ? (messages[messages.length - 1].send_date || Date.now()) : Date.now();
1927 chats.push({2137 chats.push({
1928 'file_name': chatId,2138 'file_name': chatId,
1929 'mes': lastMessage,2139 'mes': lastMessage,
1930 'last_mes': lastMessageDate,2140 'last_mes': lastMessageDate,
1931 'file_size': this_chat_file_size,2141 'file_size': fileSize,
1932 'chat_items': chat_items,2142 'chat_items': chatItems,
1933 });2143 });
1934 }2144 }
1935 } catch (err) {2145 } catch (err) {
@@ -1938,6 +2148,12 @@ export async function getGroupPastChats(groupId) {
1938 return chats;2148 return chats;
1939}2149}
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 */
1941export async function openGroupChat(groupId, chatId) {2157export async function openGroupChat(groupId, chatId) {
1942 await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);2158 await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
1943 const group = groups.find(x => x.id === groupId);2159 const group = groups.find(x => x.id === groupId);
@@ -1948,17 +2164,21 @@ export async function openGroupChat(groupId, chatId) {
19482164
1949 await clearChat();2165 await clearChat();
1950 chat.length = 0;2166 chat.length = 0;
1951 const previousChat = group.chat_id;
1952 group.past_metadata[previousChat] = Object.assign({}, chat_metadata);
1953 group.chat_id = chatId;2167 group.chat_id = chatId;
1954 group.chat_metadata = group.past_metadata[chatId] || {};
1955 group['date_last_chat'] = Date.now();2168 group['date_last_chat'] = Date.now();
1956 updateChatMetadata(group.chat_metadata, true);2169 updateChatMetadata({}, true);
19572170
1958 await editGroup(groupId, true, false);2171 await editGroup(groupId, true, false);
1959 await getGroupChat(groupId);2172 await getGroupChat(groupId);
1960}2173}
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 */
1962export async function renameGroupChat(groupId, oldChatId, newChatId) {2182export async function renameGroupChat(groupId, oldChatId, newChatId) {
1963 const group = groups.find(x => x.id === groupId);2183 const group = groups.find(x => x.id === groupId);
19642184
@@ -1972,8 +2192,6 @@ export async function renameGroupChat(groupId, oldChatId, newChatId) {
19722192
1973 group.chats.splice(group.chats.indexOf(oldChatId), 1);2193 group.chats.splice(group.chats.indexOf(oldChatId), 1);
1974 group.chats.push(newChatId);2194 group.chats.push(newChatId);
1975 group.past_metadata[newChatId] = (group.past_metadata[oldChatId] || {});
1976 delete group.past_metadata[oldChatId];
19772195
1978 await editGroup(groupId, true, true);2196 await editGroup(groupId, true, true);
1979}2197}
@@ -1990,12 +2208,7 @@ export async function deleteGroupChatByName(groupId, chatName) {
1990 return;2208 return;
1991 }2209 }
19922210
1993 if (typeof group.past_metadata !== 'object') {
1994 group.past_metadata = {};
1995 }
1996
1997 group.chats.splice(group.chats.indexOf(chatName), 1);2211 group.chats.splice(group.chats.indexOf(chatName), 1);
1998 delete group.past_metadata[chatName];
19992212
2000 const response = await fetch('/api/chats/group/delete', {2213 const response = await fetch('/api/chats/group/delete', {
2001 method: 'POST',2214 method: 'POST',
@@ -2011,12 +2224,8 @@ export async function deleteGroupChatByName(groupId, chatName) {
20112224
2012 // If the deleted chat was the current chat, switch to the last chat in the group2225 // If the deleted chat was the current chat, switch to the last chat in the group
2013 if (group.chat_id === chatName) {2226 if (group.chat_id === chatName) {
2014 group.chat_id = '';
2015 group.chat_metadata = {};
2016
2017 const newChatName = group.chats.length ? group.chats[group.chats.length - 1] : humanizedDateTime();2227 const newChatName = group.chats.length ? group.chats[group.chats.length - 1] : humanizedDateTime();
2018 group.chat_id = newChatName;2228 group.chat_id = newChatName;
2019 group.chat_metadata = group.past_metadata[newChatName] || {};
2020 }2229 }
20212230
2022 await editGroup(groupId, true, true);2231 await editGroup(groupId, true, true);
@@ -2038,12 +2247,10 @@ export async function deleteGroupChat(groupId, chatId, { jumpToNewChat = true }
2038 }2247 }
20392248
2040 group.chats.splice(group.chats.indexOf(chatId), 1);2249 group.chats.splice(group.chats.indexOf(chatId), 1);
2041 delete group.past_metadata[chatId];
20422250
2043 if (group.chat_id === chatId) {2251 if (group.chat_id === chatId) {
2044 group.chat_id = '';2252 group.chat_id = '';
2045 group.chat_metadata = {};2253 updateChatMetadata({}, true);
2046 updateChatMetadata(group.chat_metadata, true);
2047 }2254 }
20482255
2049 const response = await fetch('/api/chats/group/delete', {2256 const response = await fetch('/api/chats/group/delete', {
@@ -2101,6 +2308,14 @@ export async function importGroupChat(formData, { refresh = true } = {}) {
2101 return [];2308 return [];
2102}2309}
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 */
2104export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {2319export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
2105 const group = groups.find(x => x.id === groupId);2320 const group = groups.find(x => x.id === groupId);
21062321
@@ -2108,11 +2323,16 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
2108 return;2323 return;
2109 }2324 }
21102325
2111 group.past_metadata[name] = { ...chat_metadata, ...(metadata || {}) };
2112 group.chats.push(name);2326 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)
2116 : chat;2336 : chat;
21172337
2118 await editGroup(groupId, true, false);2338 await editGroup(groupId, true, false);
@@ -2120,7 +2340,7 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
2120 const response = await fetch('/api/chats/group/save', {2340 const response = await fetch('/api/chats/group/save', {
2121 method: 'POST',2341 method: 'POST',
2122 headers: getRequestHeaders(),2342 headers: getRequestHeaders(),
2123 body: JSON.stringify({ id: name, chat: [...trimmed_chat] }),2343 body: JSON.stringify({ id: name, chat: [chatHeader, ...trimmedChat] }),
2124 });2344 });
21252345
2126 if (!response.ok) {2346 if (!response.ok) {
public/scripts/utils.js+1 -1
@@ -2558,7 +2558,7 @@ export function findPersona({ name = null, allowAvatar = true, insensitive = tru
2558 * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by2558 * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by
2559 * @param {boolean} [options.preferCurrentChar=true] - Whether to prefer the current character(s)2559 * @param {boolean} [options.preferCurrentChar=true] - Whether to prefer the current character(s)
2560 * @param {boolean} [options.quiet=false] - Whether to suppress warnings2560 * @param {boolean} [options.quiet=false] - Whether to suppress warnings
2561 * @returns {import('./char-data.js').v1CharData?} - The found character or null if not found2561 * @returns {Character?} - The found character or null if not found
2562 */2562 */
2563export function findChar({ name = null, allowAvatar = true, insensitive = true, filteredByTags = null, preferCurrentChar = true, quiet = false } = {}) {2563export function findChar({ name = null, allowAvatar = true, insensitive = true, filteredByTags = null, preferCurrentChar = true, quiet = false } = {}) {
2564 const matches = (char) => !name || (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name);2564 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
107/**107/**
108 * Returns a greeting message for the assistant based on the character.108 * Returns a greeting message for the assistant based on the character.
109 * @param {import('./char-data.js').v1CharData} character Character data109 * @param {Character} character Character data
110 * @returns {string} Greeting message110 * @returns {string} Greeting message
111*/111*/
112function getAssistantGreeting(character) {112function getAssistantGreeting(character) {
@@ -623,7 +623,7 @@ export function assignCharacterAsAssistant(characterId) {
623 if (characterId === undefined) {623 if (characterId === undefined) {
624 return;624 return;
625 }625 }
626 /** @type {import('./char-data.js').v1CharData} */626 /** @type {Character} */
627 const character = characters[characterId];627 const character = characters[characterId];
628 if (!character) {628 if (!character) {
629 return;629 return;
src/endpoints/characters.js+1 -1
@@ -1383,7 +1383,7 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
1383 const jsonFilesPromise = jsonFiles.map((file) => {1383 const jsonFilesPromise = jsonFiles.map((file) => {
1384 const withMetadata = !!request.body.metadata;1384 const withMetadata = !!request.body.metadata;
1385 const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);1385 const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1386 return getChatInfo(pathToFile, {}, false, withMetadata);1386 return getChatInfo(pathToFile, {}, withMetadata);
1387 });1387 });
13881388
1389 const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);1389 const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
src/endpoints/chats.js+27 -19
@@ -367,11 +367,10 @@ async function checkChatIntegrity(filePath, integritySlug) {
367 * Reads the information from a chat file.367 * Reads the information from a chat file.
368 * @param {string} pathToFile - Path to the chat file368 * @param {string} pathToFile - Path to the chat file
369 * @param {object} additionalData - Additional data to include in the result369 * @param {object} additionalData - Additional data to include in the result
370 * @param {boolean} isGroup - Whether the chat is a group chat
371 * @param {boolean} withMetadata - Whether to read chat metadata370 * @param {boolean} withMetadata - Whether to read chat metadata
372 * @returns {Promise<ChatInfo>}371 * @returns {Promise<ChatInfo>}
373 */372 */
374export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false, withMetadata = false) {373export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false) {
375 return new Promise(async (res) => {374 return new Promise(async (res) => {
376 const parsedPath = path.parse(pathToFile);375 const parsedPath = path.parse(pathToFile);
377 const stats = await fs.promises.stat(pathToFile);376 const stats = await fs.promises.stat(pathToFile);
@@ -387,13 +386,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, isGroup = fal
387 ...additionalData,386 ...additionalData,
388 };387 };
389388
390 if (stats.size === 0 && !isGroup) {389 if (stats.size === 0) {
391 console.warn(`Found an empty chat file: ${pathToFile}`);
392 res({});
393 return;
394 }
395
396 if (stats.size === 0 && isGroup) {
397 res(chatData);390 res(chatData);
398 return;391 return;
399 }392 }
@@ -422,7 +415,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, isGroup = fal
422 if (lastLine) {415 if (lastLine) {
423 const jsonData = tryParse(lastLine);416 const jsonData = tryParse(lastLine);
424 if (jsonData && (jsonData.name || jsonData.character_name || jsonData.chat_metadata)) {417 if (jsonData && (jsonData.name || jsonData.character_name || jsonData.chat_metadata)) {
425 chatData.chat_items = isGroup ? itemCounter : (itemCounter - 1);418 chatData.chat_items = (itemCounter - 1);
426 chatData.mes = jsonData['mes'] || '[The message is empty]';419 chatData.mes = jsonData['mes'] || '[The message is empty]';
427 chatData.last_mes = jsonData['send_date'] || stats.mtimeMs;420 chatData.last_mes = jsonData['send_date'] || stats.mtimeMs;
428421
@@ -774,23 +767,38 @@ router.post('/group/delete', (request, response) => {
774 return response.send({ error: true });767 return response.send({ error: true });
775});768});
776769
777router.post('/group/save', (request, response) => {770router.post('/group/save', async (request, response) => {
771 try{
778 if (!request.body || !request.body.id) {772 if (!request.body || !request.body.id) {
779 return response.sendStatus(400);773 return response.sendStatus(400);
780 }774 }
781775
782 const id = request.body.id;776 const id = request.body.id;
783 const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`);777 const filePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`));
784778
785 if (!fs.existsSync(request.user.directories.groupChats)) {779 if (!fs.existsSync(request.user.directories.groupChats)) {
786 fs.mkdirSync(request.user.directories.groupChats);780 fs.mkdirSync(request.user.directories.groupChats, { recursive: true });
787 }781 }
788782
789 let chat_data = request.body.chat;783 const chatData = request.body.chat;
790 let jsonlData = chat_data.map(JSON.stringify).join('\n');784 const jsonlData = chatData.map(JSON.stringify).join('\n');
791 writeFileAtomicSync(pathToFile, jsonlData, 'utf8');785
786 if (checkIntegrity && !request.body.force) {
787 const integritySlug = chatData?.[0]?.chat_metadata?.integrity;
788 const isIntact = await checkChatIntegrity(filePath, integritySlug);
789 if (!isIntact) {
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');
792 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData);796 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData);
793 return response.send({ ok: true });797 return response.send({ ok: true });
798 } catch (error) {
799 console.error(error);
800 return response.send({ error: true });
801 }
794});802});
795803
796router.post('/search', validateAvatarUrlMiddleware, function (request, response) {804router.post('/search', validateAvatarUrlMiddleware, function (request, response) {
@@ -983,10 +991,10 @@ router.post('/recent', async function (request, response) {
983 const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER);991 const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER);
984 const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max);992 const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max);
985 const jsonFilesPromise = recentChats.map((file) => {993 const jsonFilesPromise = recentChats.map((file) => {
986 const withMetadata = Boolean(request.body.metadata);994 const withMetadata = !!request.body.metadata;
987 return file.groupId995 return file.groupId
988 ? getChatInfo(file.filePath, { group: file.groupId }, true, withMetadata)996 ? getChatInfo(file.filePath, { group: file.groupId }, withMetadata)
989 : getChatInfo(file.filePath, { avatar: file.pngFile }, false, withMetadata);997 : getChatInfo(file.filePath, { avatar: file.pngFile }, withMetadata);
990 });998 });
991999
992 const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);1000 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 {
579 const fileContent = await fs.promises.readFile(pathToFile, 'utf-8');579 const fileContent = await fs.promises.readFile(pathToFile, 'utf-8');
580 const groupData = tryParse(fileContent);580 const groupData = tryParse(fileContent);
581 if (groupData?.chat_metadata && filterFn(groupData.chat_metadata)) {581 if (groupData?.chat_metadata && filterFn(groupData.chat_metadata)) {
582 console.warn('Found group chat metadata in group definition - this is deprecated behavior.');
582 allMetadata.push(groupData.chat_metadata);583 allMetadata.push(groupData.chat_metadata);
583 }584 }
584 if (groupData?.past_metadata) {585 if (groupData?.past_metadata) {
586 console.warn('Found group past chat metadata in group definition - this is deprecated behavior.');
585 allMetadata.push(...Object.values(groupData.past_metadata).filter(filterFn));587 allMetadata.push(...Object.values(groupData.past_metadata).filter(filterFn));
586 }588 }
587 } catch (error) {589 } catch (error) {
@@ -590,6 +592,17 @@ export class DataMaidService {
590 }592 }
591 }593 }
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
593 const chatDirectories = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });606 const chatDirectories = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });
594 for (const directory of chatDirectories) {607 for (const directory of chatDirectories) {
595 if (directory.isDirectory()) {608 if (directory.isDirectory()) {
src/endpoints/groups.js+104 -3
@@ -1,15 +1,115 @@
1import fs from 'node:fs';1import fs from 'node:fs';
2import { promises as fsPromises } from 'node:fs';
2import path from 'node:path';3import path from 'node:path';
34
4import express from 'express';5import express from 'express';
5import sanitize from 'sanitize-filename';6import sanitize from 'sanitize-filename';
6import { sync as writeFileAtomicSync } from 'write-file-atomic';7import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic';
78
8import { humanizedISO8601DateTime } from '../util.js';9import { color, humanizedISO8601DateTime, tryParse } from '../util.js';
9import { getFileNameValidationFunction } from '../middleware/validateFileName.js';10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1011
11export const router = express.Router();12export 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 */
18function 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 */
34export 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
13router.post('/all', (request, response) => {113router.post('/all', (request, response) => {
14 const groups = [];114 const groups = [];
15115
@@ -59,6 +159,7 @@ router.post('/create', (request, response) => {
59 return response.sendStatus(400);159 return response.sendStatus(400);
60 }160 }
61161
162 warnOnGroupMetadata(request.body);
62 const id = String(Date.now());163 const id = String(Date.now());
63 const groupMetadata = {164 const groupMetadata = {
64 id: id,165 id: id,
@@ -69,7 +170,6 @@ router.post('/create', (request, response) => {
69 activation_strategy: request.body.activation_strategy ?? 0,170 activation_strategy: request.body.activation_strategy ?? 0,
70 generation_mode: request.body.generation_mode ?? 0,171 generation_mode: request.body.generation_mode ?? 0,
71 disabled_members: request.body.disabled_members ?? [],172 disabled_members: request.body.disabled_members ?? [],
72 chat_metadata: request.body.chat_metadata ?? {},
73 fav: request.body.fav,173 fav: request.body.fav,
74 chat_id: request.body.chat_id ?? id,174 chat_id: request.body.chat_id ?? id,
75 chats: request.body.chats ?? [id],175 chats: request.body.chats ?? [id],
@@ -92,6 +192,7 @@ router.post('/edit', getFileNameValidationFunction('id'), (request, response) =>
92 if (!request.body || !request.body.id) {192 if (!request.body || !request.body.id) {
93 return response.sendStatus(400);193 return response.sendStatus(400);
94 }194 }
195 warnOnGroupMetadata(request.body);
95 const id = request.body.id;196 const id = request.body.id;
96 const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`));197 const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`));
97 const fileData = JSON.stringify(request.body, null, 4);198 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';
68import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';68import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
69import { diskCache } from './endpoints/characters.js';69import { diskCache } from './endpoints/characters.js';
70import { migrateFlatSecrets } from './endpoints/secrets.js';70import { migrateFlatSecrets } from './endpoints/secrets.js';
71import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js';
7172
72// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.73// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
73// https://github.com/nodejs/node/issues/47822#issuecomment-156470887074// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -265,6 +266,7 @@ async function preSetupTasks() {
265 console.log();266 console.log();
266267
267 const directories = await getUserDirectoriesList();268 const directories = await getUserDirectoriesList();
269 await migrateGroupChatsMetadataFormat(directories);
268 await checkForNewContent(directories);270 await checkForNewContent(directories);
269 await ensureThumbnailCache(directories);271 await ensureThumbnailCache(directories);
270 await diskCache.verify(directories);272 await diskCache.verify(directories);