Recent chats: add delete and rename buttons (#4051) * [wip] Add rename/delete for recent chats * Implement deleteCharacterChatByName * Fix character name usage in deleteCharacterChatByName function

e1e2d3e726d74250bb4847b3bab44823354c4716

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

Signed
5 files changed, +388 -26Ignore whitespace
public/css/welcome.css+16 -0
@@ -142,12 +142,28 @@ body.hideChatAvatars .welcomePanel .recentChatList .recentChat .avatar {
142142 justify-content: space-between;
143143 align-items: baseline;
144144 font-size: calc(var(--mainFontSize) * 1);
145+ gap: 5px;
145146}
146147
147148.welcomeRecent .recentChatList .recentChat .chatNameContainer .chatName {
148149 white-space: nowrap;
149150 text-overflow: ellipsis;
150151 overflow: hidden;
152+ flex: 1;
153+}
154+
155+.welcomeRecent .recentChatList .recentChat .chatActions {
156+ display: flex;
157+ flex-direction: row;
158+ align-items: center;
159+ justify-content: center;
160+ gap: 5px;
161+}
162+
163+.welcomeRecent .recentChatList .recentChat .chatActions button {
164+ margin: 0;
165+ font-size: 0.8em;
166+ cursor: pointer;
151167}
152168
153169.welcomeRecent .recentChatList .recentChat .chatMessageContainer {
public/script.js+115 -21
@@ -1880,6 +1880,52 @@ async function delChat(chatfile) {
18801880 }
18811881}
18821882
1883+/**
1884+ * Deletes a character chat by its name.
1885+ * @param {string} characterId Character ID to delete chat for
1886+ * @param {string} fileName Name of the chat file to delete (without .jsonl extension)
1887+ * @returns {Promise<void>} A promise that resolves when the chat is deleted.
1888+ */
1889+export async function deleteCharacterChatByName(characterId, fileName) {
1890+ // Make sure all the data is loaded.
1891+ await unshallowCharacter(characterId);
1892+
1893+ /** @type {import('./scripts/char-data.js').v1CharData} */
1894+ const character = characters[characterId];
1895+ if (!character) {
1896+ console.warn(`Character with ID ${characterId} not found.`);
1897+ return;
1898+ }
1899+
1900+ const response = await fetch('/api/chats/delete', {
1901+ method: 'POST',
1902+ headers: getRequestHeaders(),
1903+ body: JSON.stringify({
1904+ chatfile: `${fileName}.jsonl`,
1905+ avatar_url: character.avatar,
1906+ }),
1907+ });
1908+
1909+ if (!response.ok) {
1910+ console.error('Failed to delete chat for character.');
1911+ return;
1912+ }
1913+
1914+ if (fileName === character.chat) {
1915+ const chatsResponse = await fetch('/api/characters/chats', {
1916+ method: 'POST',
1917+ headers: getRequestHeaders(),
1918+ body: JSON.stringify({ avatar_url: character.avatar }),
1919+ });
1920+ const chats = Object.values(await chatsResponse.json());
1921+ chats.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
1922+ const newChatName = chats.length && typeof chats[0] === 'object' ? chats[0].file_name.replace('.jsonl', '') : `${character.name} - ${humanizedDateTime()}`;
1923+ await updateRemoteChatName(characterId, newChatName);
1924+ }
1925+
1926+ await eventSource.emit(event_types.CHAT_DELETED, fileName);
1927+}
1928+
18831929export async function replaceCurrentChat() {
18841930 await clearChat();
18851931 chat.length = 0;
@@ -9985,16 +10031,21 @@ async function doRenameChat(_, chatName) {
998510031}
998610032
998710033/**
998810034 * Renames thea currentlygroup selectedor character chat.
998910035 * @param {stringobject} oldFileName Oldparam nameParameters offor therenaming chat (no JSONL extension)
999010036 * @param {string} newName[param.characterId] NewCharacter nameID forto therename chat (no JSONL extension)for
10037+ * @param {string} [param.groupId] Group ID to rename chat for
10038+ * @param {string} param.oldFileName Old name of the chat (no JSONL extension)
10039+ * @param {string} param.newFileName New name for the chat (no JSONL extension)
10040+ * @param {boolean} [param.loader=true] Whether to show loader during the operation
999110041 */
999210042export async function renameChatrenameGroupOrCharacterChat({ characterId, groupId, oldFileName, newNamenewFileName, loader }) {
10043+ const currentChatId = getCurrentChatId();
999310044 const body = {
999410045 is_group: !!selected_groupgroupId,
999510046 avatar_url: characters[this_chidcharacterId]?.avatar,
999610047 original_file: `${oldFileName}.jsonl`,
999710048 renamed_file: `${newNamenewFileName.trim()}.jsonl`,
999810049 };
999910050
1000010051 if (body.original_file === body.renamed_file) {
@@ -10007,7 +10058,8 @@ export async function renameChat(oldFileName, newName) {
1000710058 }
1000810059
1000910060 try {
1001010061 loader && showLoader();
10062+
1001110063 const response = await fetch('/api/chats/rename', {
1001210064 method: 'POST',
1001310065 body: JSON.stringify(body),
@@ -10025,27 +10077,69 @@ export async function renameChat(oldFileName, newName) {
1002510077 }
1002610078
1002710079 if (data.sanitizedFileName) {
1002810080 newNamenewFileName = data.sanitizedFileName;
1002910081 }
1003010082
1003110083 if (selected_groupgroupId) {
1003210084 await renameGroupChat(selected_groupgroupId, oldFileName, newNamenewFileName);
1003310085 }
10034- else {
10086+ else if (characterId !== undefined && String(characterId) === String(this_chid) && characters[characterId]?.chat === oldFileName) {
1003510087 if (characters[this_chidcharacterId].chat == oldFileName) {newFileName;
10036- characters[this_chid].chat = newName;
10088+ $('#selected_chat_pole').val(characters[characterId].chat);
10037- $('#selected_chat_pole').val(characters[this_chid].chat);
10089+ await createOrEditCharacter();
10038- await createOrEditCharacter();
10039- }
1004010090 }
1004110091
1004210092 awaitif reloadCurrentChat(currentChatId); {
10093+ await reloadCurrentChat();
10094+ }
1004310095 } catch {
1004410096 loader && hideLoader();
1004510097 await delay(500);
1004610098 await callPopupcallGenericPopup('An error has occurred. Chat was not renamed.', 'text'POPUP_TYPE.TEXT);
1004710099 } finally {
1004810100 loader && hideLoader();
10101+ }
10102+}
10103+
10104+/**
10105+ * Renames the currently selected chat.
10106+ * @param {string} oldFileName Old name of the chat (no JSONL extension)
10107+ * @param {string} newName New name for the chat (no JSONL extension)
10108+ */
10109+export async function renameChat(oldFileName, newName) {
10110+ return await renameGroupOrCharacterChat({
10111+ characterId: this_chid,
10112+ groupId: selected_group,
10113+ oldFileName: oldFileName,
10114+ newFileName: newName,
10115+ loader: true,
10116+ });
10117+}
10118+
10119+/**
10120+ * Forces the update of the chat name for a remote character.
10121+ * @param {string|number} characterId Character ID to update chat name for
10122+ * @param {string} newName New name for the chat
10123+ * @returns {Promise<void>}
10124+ */
10125+export async function updateRemoteChatName(characterId, newName) {
10126+ const character = characters[characterId];
10127+ if (!character) {
10128+ console.warn(`Character not found for ID: ${characterId}`);
10129+ return;
10130+ }
10131+ character.chat = newName;
10132+ const mergeRequest = {
10133+ avatar: character.avatar,
10134+ chat: newName,
10135+ };
10136+ const mergeResponse = await fetch('/api/characters/merge-attributes', {
10137+ method: 'POST',
10138+ headers: getRequestHeaders(),
10139+ body: JSON.stringify(mergeRequest),
10140+ });
10141+ if (!mergeResponse.ok) {
10142+ console.error('Failed to save extension field', mergeResponse.statusText);
1004910143 }
1005010144}
1005110145
public/scripts/group-chats.js+45 -0
@@ -1966,6 +1966,51 @@ export async function renameGroupChat(groupId, oldChatId, newChatId) {
19661966 await editGroup(groupId, true, true);
19671967}
19681968
1969+/**
1970+ * Deletes a group chat by its name. Doesn't affect displayed chat.
1971+ * @param {string} groupId Group ID
1972+ * @param {string} chatName Name of the chat to delete
1973+ * @returns {Promise<void>}
1974+ */
1975+export async function deleteGroupChatByName(groupId, chatName) {
1976+ const group = groups.find(x => x.id === groupId);
1977+ if (!group || !group.chats.includes(chatName)) {
1978+ return;
1979+ }
1980+
1981+ if (typeof group.past_metadata !== 'object') {
1982+ group.past_metadata = {};
1983+ }
1984+
1985+ group.chats.splice(group.chats.indexOf(chatName), 1);
1986+ delete group.past_metadata[chatName];
1987+
1988+ const response = await fetch('/api/chats/group/delete', {
1989+ method: 'POST',
1990+ headers: getRequestHeaders(),
1991+ body: JSON.stringify({ id: chatName }),
1992+ });
1993+
1994+ if (!response.ok) {
1995+ toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group chat could not be deleted`);
1996+ console.error('Group chat could not be deleted');
1997+ return;
1998+ }
1999+
2000+ // If the deleted chat was the current chat, switch to the last chat in the group
2001+ if (group.chat_id === chatName) {
2002+ group.chat_id = '';
2003+ group.chat_metadata = {};
2004+
2005+ const newChatName = group.chats.length ? group.chats[group.chats.length - 1] : humanizedDateTime();
2006+ group.chat_id = newChatName;
2007+ group.chat_metadata = group.past_metadata[newChatName] || {};
2008+ }
2009+
2010+ await editGroup(groupId, true, true);
2011+ await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatName);
2012+}
2013+
19692014export async function deleteGroupChat(groupId, chatId) {
19702015 const group = groups.find(x => x.id === groupId);
19712016
public/scripts/templates/welcomePanel.html+8 -0
@@ -55,6 +55,14 @@
5555 <span>{{chat_name}}</span>
5656 </div>
5757 <small class="chatDate" title="{{date_long}}">{{date_short}}</small>
58+ <div class="chatActions">
59+ <button class="menu_button menu_button_icon renameChat" title="Rename chat" data-i18n="[title]Rename chat">
60+ <i class="fa-solid fa-pen-to-square fa-fw"></i>
61+ </button>
62+ <button class="menu_button menu_button_icon deleteChat" title="Delete chat" data-i18n="[title]Delete chat">
63+ <i class="fa-solid fa-trash fa-fw"></i>
64+ </button>
65+ </div>
5866 </div>
5967 <div class="chatMessageContainer">
6068 <div class="chatMessage" title="{{mes}}">
public/scripts/welcome-screen.js+204 -5
@@ -2,6 +2,7 @@ import {
22 addOneMessage,
33 characters,
44 chat,
5+ deleteCharacterChatByName,
56 displayVersion,
67 doNewChat,
78 event_types,
@@ -16,15 +17,18 @@ import {
1617 newAssistantChat,
1718 openCharacterChat,
1819 printCharactersDebounced,
20+ renameGroupOrCharacterChat,
1921 selectCharacterById,
2022 system_avatar,
2123 system_message_types,
2224 this_chid,
2325 unshallowCharacter,
26+ updateRemoteChatName,
2427} from '../script.js';
2528import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
2629import { deleteGroupChatByName, getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js';
2730import { t } from './i18n.js';
31+import { callGenericPopup, POPUP_TYPE } from './popup.js';
2832import { getMessageTimeStamp } from './RossAscends-mods.js';
2933import { renderTemplateAsync } from './templates.js';
3034import { accountStorage } from './util/AccountStorage.js';
@@ -51,9 +55,16 @@ export function getPermanentAssistantAvatar() {
5155 return assistantAvatar;
5256}
5357
54-export async function openWelcomeScreen() {
58+/**
59+ * Opens a welcome screen if no chat is currently active.
60+ * @param {object} param Additional parameters
61+ * @param {boolean} [param.force] If true, forces clearing of the welcome screen.
62+ * @param {boolean} [param.expand] If true, expands the recent chats section.
63+ * @returns {Promise<void>}
64+ */
65+export async function openWelcomeScreen({ force = false, expand = false } = {}) {
5566 const currentChatId = getCurrentChatId();
5667 if (currentChatId !== undefined || (chat.length > 0 && !force)) {
5768 return;
5869 }
5970
@@ -64,7 +75,13 @@ export async function openWelcomeScreen() {
6475 return;
6576 }
6677
67- await sendWelcomePanel(recentChats);
78+ if (chatAfterFetch === undefined && force) {
79+ console.debug('Forcing welcome screen open.');
80+ chat.splice(0, chat.length);
81+ $('#chat').empty();
82+ }
83+
84+ await sendWelcomePanel(recentChats, expand);
6885 await unshallowPermanentAssistant();
6986 sendAssistantMessage();
7087 sendWelcomePrompt();
@@ -131,8 +148,9 @@ function sendWelcomePrompt() {
131148/**
132149 * Sends the welcome panel to the chat.
133150 * @param {RecentChat[]} chats List of recent chats
151+ * @param {boolean} [expand=false] If true, expands the recent chats section
134152 */
135153async function sendWelcomePanel(chats, expand = false) {
136154 try {
137155 const chatElement = document.getElementById('chat');
138156 const sendTextArea = document.getElementById('send_textarea');
@@ -215,7 +233,50 @@ async function sendWelcomePanel(chats) {
215233 $(avatar).replaceWith(groupAvatar);
216234 }
217235 });
236+ fragment.querySelectorAll('.recentChat .renameChat').forEach((renameButton) => {
237+ renameButton.addEventListener('click', (event) => {
238+ event.stopPropagation();
239+ const chatItem = renameButton.closest('.recentChat');
240+ if (!chatItem) {
241+ return;
242+ }
243+ const avatarId = chatItem.getAttribute('data-avatar');
244+ const groupId = chatItem.getAttribute('data-group');
245+ const fileName = chatItem.getAttribute('data-file');
246+ if (avatarId && fileName) {
247+ void renameRecentCharacterChat(avatarId, fileName);
248+ }
249+ if (groupId && fileName) {
250+ void renameRecentGroupChat(groupId, fileName);
251+ }
252+ });
253+ });
254+ fragment.querySelectorAll('.recentChat .deleteChat').forEach((deleteButton) => {
255+ deleteButton.addEventListener('click', (event) => {
256+ event.stopPropagation();
257+ const chatItem = deleteButton.closest('.recentChat');
258+ if (!chatItem) {
259+ return;
260+ }
261+ const avatarId = chatItem.getAttribute('data-avatar');
262+ const groupId = chatItem.getAttribute('data-group');
263+ const fileName = chatItem.getAttribute('data-file');
264+ if (avatarId && fileName) {
265+ void deleteRecentCharacterChat(avatarId, fileName);
266+ }
267+ if (groupId && fileName) {
268+ void deleteRecentGroupChat(groupId, fileName);
269+ }
270+ });
271+ });
218272 chatElement.append(fragment.firstChild);
273+ if (expand) {
274+ chatElement.querySelectorAll('button.showMoreChats').forEach((button) => {
275+ if (button instanceof HTMLButtonElement) {
276+ button.click();
277+ }
278+ });
279+ }
219280 } catch (error) {
220281 console.error('Welcome screen error:', error);
221282 }
@@ -274,6 +335,144 @@ async function openRecentGroupChat(groupId, fileName) {
274335}
275336
276337/**
338+ * Renames a recent character chat.
339+ * @param {string} avatarId Avatar file name
340+ * @param {string} fileName Chat file name
341+ */
342+async function renameRecentCharacterChat(avatarId, fileName) {
343+ const characterId = characters.findIndex(x => x.avatar === avatarId);
344+ if (characterId === -1) {
345+ console.error(`Character not found for avatar ID: ${avatarId}`);
346+ return;
347+ }
348+ try {
349+ const popupText = await renderTemplateAsync('chatRename');
350+ const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, fileName);
351+ if (!newName || typeof newName !== 'string' || newName === fileName) {
352+ console.log('No new name provided, aborting');
353+ return;
354+ }
355+ await renameGroupOrCharacterChat({
356+ characterId: String(characterId),
357+ oldFileName: fileName,
358+ newFileName: newName,
359+ loader: false,
360+ });
361+ await updateRemoteChatName(characterId, newName);
362+ await refreshWelcomeScreen();
363+ toastr.success(t`Chat renamed.`);
364+ } catch (error) {
365+ console.error('Error renaming recent character chat:', error);
366+ toastr.error(t`Failed to rename recent chat. See console for details.`);
367+ }
368+}
369+
370+/**
371+ * Renames a recent group chat.
372+ * @param {string} groupId Group ID
373+ * @param {string} fileName Chat file name
374+ */
375+async function renameRecentGroupChat(groupId, fileName) {
376+ const group = groups.find(x => x.id === groupId);
377+ if (!group) {
378+ console.error(`Group not found for ID: ${groupId}`);
379+ return;
380+ }
381+ try {
382+ const popupText = await renderTemplateAsync('chatRename');
383+ const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, fileName);
384+ if (!newName || newName === fileName) {
385+ console.log('No new name provided, aborting');
386+ return;
387+ }
388+ await renameGroupOrCharacterChat({
389+ groupId: String(groupId),
390+ oldFileName: fileName,
391+ newFileName: String(newName),
392+ loader: false,
393+ });
394+ await refreshWelcomeScreen();
395+ toastr.success(t`Group chat renamed.`);
396+ } catch (error) {
397+ console.error('Error renaming recent group chat:', error);
398+ toastr.error(t`Failed to rename recent group chat. See console for details.`);
399+ }
400+}
401+
402+/**
403+ * Deletes a recent character chat.
404+ * @param {string} avatarId Avatar file name
405+ * @param {string} fileName Chat file name
406+ */
407+async function deleteRecentCharacterChat(avatarId, fileName) {
408+ const characterId = characters.findIndex(x => x.avatar === avatarId);
409+ if (characterId === -1) {
410+ console.error(`Character not found for avatar ID: ${avatarId}`);
411+ return;
412+ }
413+ try {
414+ const confirm = await callGenericPopup(t`Delete the Chat File?`, POPUP_TYPE.CONFIRM);
415+ if (!confirm) {
416+ console.log('Deletion cancelled by user');
417+ return;
418+ }
419+ await deleteCharacterChatByName(String(characterId), fileName);
420+ await refreshWelcomeScreen();
421+ toastr.success(t`Chat deleted.`);
422+ } catch (error) {
423+ console.error('Error deleting recent character chat:', error);
424+ toastr.error(t`Failed to delete recent chat. See console for details.`);
425+ }
426+}
427+
428+/**
429+ * Deletes a recent group chat.
430+ * @param {string} groupId Group ID
431+ * @param {string} fileName Chat file name
432+ */
433+async function deleteRecentGroupChat(groupId, fileName) {
434+ const group = groups.find(x => x.id === groupId);
435+ if (!group) {
436+ console.error(`Group not found for ID: ${groupId}`);
437+ return;
438+ }
439+ try {
440+ const confirm = await callGenericPopup(t`Delete the Chat File?`, POPUP_TYPE.CONFIRM);
441+ if (!confirm) {
442+ console.log('Deletion cancelled by user');
443+ return;
444+ }
445+ await deleteGroupChatByName(groupId, fileName);
446+ await refreshWelcomeScreen();
447+ toastr.success(t`Group chat deleted.`);
448+ } catch (error) {
449+ console.error('Error deleting recent group chat:', error);
450+ toastr.error(t`Failed to delete recent group chat. See console for details.`);
451+ }
452+}
453+
454+/**
455+ * Reopens the welcome screen and restores the scroll position.
456+ * @returns {Promise<void>}
457+ */
458+async function refreshWelcomeScreen() {
459+ const chatElement = document.getElementById('chat');
460+ if (!chatElement) {
461+ console.error('Chat element not found');
462+ return;
463+ }
464+
465+ const scrollTop = chatElement.scrollTop;
466+ const scrollHeight = chatElement.scrollHeight;
467+ const expand = chatElement.querySelectorAll('button.showMoreChats.rotated').length > 0;
468+
469+ await openWelcomeScreen({ force: true, expand });
470+
471+ // Restore scroll position
472+ chatElement.scrollTop = scrollTop + (chatElement.scrollHeight - scrollHeight);
473+}
474+
475+/**
277476 * Gets the list of recent chats from the server.
278477 * @returns {Promise<RecentChat[]>} List of recent chats
279478 *