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, +386 -24Showing whitespace changes
public/css/welcome.css+16 -0
@@ -142,12 +142,28 @@ body.hideChatAvatars .welcomePanel .recentChatList .recentChat .avatar {
142 justify-content: space-between;142 justify-content: space-between;
143 align-items: baseline;143 align-items: baseline;
144 font-size: calc(var(--mainFontSize) * 1);144 font-size: calc(var(--mainFontSize) * 1);
145 gap: 5px;
145}146}
146147
147.welcomeRecent .recentChatList .recentChat .chatNameContainer .chatName {148.welcomeRecent .recentChatList .recentChat .chatNameContainer .chatName {
148 white-space: nowrap;149 white-space: nowrap;
149 text-overflow: ellipsis;150 text-overflow: ellipsis;
150 overflow: hidden;151 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;
151}167}
152168
153.welcomeRecent .recentChatList .recentChat .chatMessageContainer {169.welcomeRecent .recentChatList .recentChat .chatMessageContainer {
public/script.js+113 -19
@@ -1880,6 +1880,52 @@ async function delChat(chatfile) {
1880 }1880 }
1881}1881}
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 */
1889export 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
1883export async function replaceCurrentChat() {1929export async function replaceCurrentChat() {
1884 await clearChat();1930 await clearChat();
1885 chat.length = 0;1931 chat.length = 0;
@@ -9985,16 +10031,21 @@ async function doRenameChat(_, chatName) {
9985}10031}
998610032
9987/**10033/**
9988 * Renames the currently selected chat.10034 * Renames a group or character chat.
9989 * @param {string} oldFileName Old name of the chat (no JSONL extension)10035 * @param {object} param Parameters for renaming chat
9990 * @param {string} newName New name for the chat (no JSONL extension)10036 * @param {string} [param.characterId] Character ID to rename chat 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
9991 */10041 */
9992export async function renameChat(oldFileName, newName) {10042export async function renameGroupOrCharacterChat({ characterId, groupId, oldFileName, newFileName, loader }) {
10043 const currentChatId = getCurrentChatId();
9993 const body = {10044 const body = {
9994 is_group: !!selected_group,10045 is_group: !!groupId,
9995 avatar_url: characters[this_chid]?.avatar,10046 avatar_url: characters[characterId]?.avatar,
9996 original_file: `${oldFileName}.jsonl`,10047 original_file: `${oldFileName}.jsonl`,
9997 renamed_file: `${newName.trim()}.jsonl`,10048 renamed_file: `${newFileName.trim()}.jsonl`,
9998 };10049 };
999910050
10000 if (body.original_file === body.renamed_file) {10051 if (body.original_file === body.renamed_file) {
@@ -10007,7 +10058,8 @@ export async function renameChat(oldFileName, newName) {
10007 }10058 }
1000810059
10009 try {10060 try {
10010 showLoader();10061 loader && showLoader();
10062
10011 const response = await fetch('/api/chats/rename', {10063 const response = await fetch('/api/chats/rename', {
10012 method: 'POST',10064 method: 'POST',
10013 body: JSON.stringify(body),10065 body: JSON.stringify(body),
@@ -10025,27 +10077,69 @@ export async function renameChat(oldFileName, newName) {
10025 }10077 }
1002610078
10027 if (data.sanitizedFileName) {10079 if (data.sanitizedFileName) {
10028 newName = data.sanitizedFileName;10080 newFileName = data.sanitizedFileName;
10029 }10081 }
1003010082
10031 if (selected_group) {10083 if (groupId) {
10032 await renameGroupChat(selected_group, oldFileName, newName);10084 await renameGroupChat(groupId, oldFileName, newFileName);
10033 }10085 }
10034 else {10086 else if (characterId !== undefined && String(characterId) === String(this_chid) && characters[characterId]?.chat === oldFileName) {
10035 if (characters[this_chid].chat == oldFileName) {10087 characters[characterId].chat = newFileName;
10036 characters[this_chid].chat = newName;10088 $('#selected_chat_pole').val(characters[characterId].chat);
10037 $('#selected_chat_pole').val(characters[this_chid].chat);
10038 await createOrEditCharacter();10089 await createOrEditCharacter();
10039 }10090 }
10040 }
1004110091
10092 if (currentChatId) {
10042 await reloadCurrentChat();10093 await reloadCurrentChat();
10094 }
10043 } catch {10095 } catch {
10044 hideLoader();10096 loader && hideLoader();
10045 await delay(500);10097 await delay(500);
10046 await callPopup('An error has occurred. Chat was not renamed.', 'text');10098 await callGenericPopup('An error has occurred. Chat was not renamed.', POPUP_TYPE.TEXT);
10047 } finally {10099 } finally {
10048 hideLoader();10100 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 */
10109export 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 */
10125export 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);
10049 }10143 }
10050}10144}
1005110145
public/scripts/group-chats.js+45 -0
@@ -1966,6 +1966,51 @@ export async function renameGroupChat(groupId, oldChatId, newChatId) {
1966 await editGroup(groupId, true, true);1966 await editGroup(groupId, true, true);
1967}1967}
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 */
1975export 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
1969export async function deleteGroupChat(groupId, chatId) {2014export async function deleteGroupChat(groupId, chatId) {
1970 const group = groups.find(x => x.id === groupId);2015 const group = groups.find(x => x.id === groupId);
19712016
public/scripts/templates/welcomePanel.html+8 -0
@@ -55,6 +55,14 @@
55 <span>{{chat_name}}</span>55 <span>{{chat_name}}</span>
56 </div>56 </div>
57 <small class="chatDate" title="{{date_long}}">{{date_short}}</small>57 <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>
58 </div>66 </div>
59 <div class="chatMessageContainer">67 <div class="chatMessageContainer">
60 <div class="chatMessage" title="{{mes}}">68 <div class="chatMessage" title="{{mes}}">
public/scripts/welcome-screen.js+204 -5
@@ -2,6 +2,7 @@ import {
2 addOneMessage,2 addOneMessage,
3 characters,3 characters,
4 chat,4 chat,
5 deleteCharacterChatByName,
5 displayVersion,6 displayVersion,
6 doNewChat,7 doNewChat,
7 event_types,8 event_types,
@@ -16,15 +17,18 @@ import {
16 newAssistantChat,17 newAssistantChat,
17 openCharacterChat,18 openCharacterChat,
18 printCharactersDebounced,19 printCharactersDebounced,
20 renameGroupOrCharacterChat,
19 selectCharacterById,21 selectCharacterById,
20 system_avatar,22 system_avatar,
21 system_message_types,23 system_message_types,
22 this_chid,24 this_chid,
23 unshallowCharacter,25 unshallowCharacter,
26 updateRemoteChatName,
24} from '../script.js';27} from '../script.js';
25import { getRegexedString, regex_placement } from './extensions/regex/engine.js';28import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
26import { getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js';29import { deleteGroupChatByName, getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js';
27import { t } from './i18n.js';30import { t } from './i18n.js';
31import { callGenericPopup, POPUP_TYPE } from './popup.js';
28import { getMessageTimeStamp } from './RossAscends-mods.js';32import { getMessageTimeStamp } from './RossAscends-mods.js';
29import { renderTemplateAsync } from './templates.js';33import { renderTemplateAsync } from './templates.js';
30import { accountStorage } from './util/AccountStorage.js';34import { accountStorage } from './util/AccountStorage.js';
@@ -51,9 +55,16 @@ export function getPermanentAssistantAvatar() {
51 return assistantAvatar;55 return assistantAvatar;
52}56}
5357
54export 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 */
65export async function openWelcomeScreen({ force = false, expand = false } = {}) {
55 const currentChatId = getCurrentChatId();66 const currentChatId = getCurrentChatId();
56 if (currentChatId !== undefined || chat.length > 0) {67 if (currentChatId !== undefined || (chat.length > 0 && !force)) {
57 return;68 return;
58 }69 }
5970
@@ -64,7 +75,13 @@ export async function openWelcomeScreen() {
64 return;75 return;
65 }76 }
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);
68 await unshallowPermanentAssistant();85 await unshallowPermanentAssistant();
69 sendAssistantMessage();86 sendAssistantMessage();
70 sendWelcomePrompt();87 sendWelcomePrompt();
@@ -131,8 +148,9 @@ function sendWelcomePrompt() {
131/**148/**
132 * Sends the welcome panel to the chat.149 * Sends the welcome panel to the chat.
133 * @param {RecentChat[]} chats List of recent chats150 * @param {RecentChat[]} chats List of recent chats
151 * @param {boolean} [expand=false] If true, expands the recent chats section
134 */152 */
135async function sendWelcomePanel(chats) {153async function sendWelcomePanel(chats, expand = false) {
136 try {154 try {
137 const chatElement = document.getElementById('chat');155 const chatElement = document.getElementById('chat');
138 const sendTextArea = document.getElementById('send_textarea');156 const sendTextArea = document.getElementById('send_textarea');
@@ -215,7 +233,50 @@ async function sendWelcomePanel(chats) {
215 $(avatar).replaceWith(groupAvatar);233 $(avatar).replaceWith(groupAvatar);
216 }234 }
217 });235 });
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 });
218 chatElement.append(fragment.firstChild);272 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 }
219 } catch (error) {280 } catch (error) {
220 console.error('Welcome screen error:', error);281 console.error('Welcome screen error:', error);
221 }282 }
@@ -274,6 +335,144 @@ async function openRecentGroupChat(groupId, fileName) {
274}335}
275336
276/**337/**
338 * Renames a recent character chat.
339 * @param {string} avatarId Avatar file name
340 * @param {string} fileName Chat file name
341 */
342async 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 */
375async 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 */
407async 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 */
433async 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 */
458async 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/**
277 * Gets the list of recent chats from the server.476 * Gets the list of recent chats from the server.
278 * @returns {Promise<RecentChat[]>} List of recent chats477 * @returns {Promise<RecentChat[]>} List of recent chats
279 *478 *