diff --git a/public/css/welcome.css b/public/css/welcome.css index 1d7a15dce..e92ed9ea5 100644 --- a/public/css/welcome.css +++ b/public/css/welcome.css @@ -142,12 +142,28 @@ body.hideChatAvatars .welcomePanel .recentChatList .recentChat .avatar { justify-content: space-between; align-items: baseline; font-size: calc(var(--mainFontSize) * 1); + gap: 5px; } .welcomeRecent .recentChatList .recentChat .chatNameContainer .chatName { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; + flex: 1; +} + +.welcomeRecent .recentChatList .recentChat .chatActions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 5px; +} + +.welcomeRecent .recentChatList .recentChat .chatActions button { + margin: 0; + font-size: 0.8em; + cursor: pointer; } .welcomeRecent .recentChatList .recentChat .chatMessageContainer { diff --git a/public/script.js b/public/script.js index e3a3c42d0..4afe9ec21 100644 --- a/public/script.js +++ b/public/script.js @@ -1880,6 +1880,52 @@ async function delChat(chatfile) { } } +/** + * Deletes a character chat by its name. + * @param {string} characterId Character ID to delete chat for + * @param {string} fileName Name of the chat file to delete (without .jsonl extension) + * @returns {Promise} A promise that resolves when the chat is deleted. + */ +export async function deleteCharacterChatByName(characterId, fileName) { + // Make sure all the data is loaded. + await unshallowCharacter(characterId); + + /** @type {import('./scripts/char-data.js').v1CharData} */ + const character = characters[characterId]; + if (!character) { + console.warn(`Character with ID ${characterId} not found.`); + return; + } + + const response = await fetch('/api/chats/delete', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + chatfile: `${fileName}.jsonl`, + avatar_url: character.avatar, + }), + }); + + if (!response.ok) { + console.error('Failed to delete chat for character.'); + return; + } + + if (fileName === character.chat) { + const chatsResponse = await fetch('/api/characters/chats', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ avatar_url: character.avatar }), + }); + const chats = Object.values(await chatsResponse.json()); + chats.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))); + const newChatName = chats.length && typeof chats[0] === 'object' ? chats[0].file_name.replace('.jsonl', '') : `${character.name} - ${humanizedDateTime()}`; + await updateRemoteChatName(characterId, newChatName); + } + + await eventSource.emit(event_types.CHAT_DELETED, fileName); +} + export async function replaceCurrentChat() { await clearChat(); chat.length = 0; @@ -9985,16 +10031,21 @@ async function doRenameChat(_, chatName) { } /** - * Renames the currently selected chat. - * @param {string} oldFileName Old name of the chat (no JSONL extension) - * @param {string} newName New name for the chat (no JSONL extension) + * Renames a group or character chat. + * @param {object} param Parameters for renaming chat + * @param {string} [param.characterId] Character ID to rename chat for + * @param {string} [param.groupId] Group ID to rename chat for + * @param {string} param.oldFileName Old name of the chat (no JSONL extension) + * @param {string} param.newFileName New name for the chat (no JSONL extension) + * @param {boolean} [param.loader=true] Whether to show loader during the operation */ -export async function renameChat(oldFileName, newName) { +export async function renameGroupOrCharacterChat({ characterId, groupId, oldFileName, newFileName, loader }) { + const currentChatId = getCurrentChatId(); const body = { - is_group: !!selected_group, - avatar_url: characters[this_chid]?.avatar, + is_group: !!groupId, + avatar_url: characters[characterId]?.avatar, original_file: `${oldFileName}.jsonl`, - renamed_file: `${newName.trim()}.jsonl`, + renamed_file: `${newFileName.trim()}.jsonl`, }; if (body.original_file === body.renamed_file) { @@ -10007,7 +10058,8 @@ export async function renameChat(oldFileName, newName) { } try { - showLoader(); + loader && showLoader(); + const response = await fetch('/api/chats/rename', { method: 'POST', body: JSON.stringify(body), @@ -10025,27 +10077,69 @@ export async function renameChat(oldFileName, newName) { } if (data.sanitizedFileName) { - newName = data.sanitizedFileName; + newFileName = data.sanitizedFileName; } - if (selected_group) { - await renameGroupChat(selected_group, oldFileName, newName); + if (groupId) { + await renameGroupChat(groupId, oldFileName, newFileName); } - else { - if (characters[this_chid].chat == oldFileName) { - characters[this_chid].chat = newName; - $('#selected_chat_pole').val(characters[this_chid].chat); - await createOrEditCharacter(); - } + else if (characterId !== undefined && String(characterId) === String(this_chid) && characters[characterId]?.chat === oldFileName) { + characters[characterId].chat = newFileName; + $('#selected_chat_pole').val(characters[characterId].chat); + await createOrEditCharacter(); } - await reloadCurrentChat(); + if (currentChatId) { + await reloadCurrentChat(); + } } catch { - hideLoader(); + loader && hideLoader(); await delay(500); - await callPopup('An error has occurred. Chat was not renamed.', 'text'); + await callGenericPopup('An error has occurred. Chat was not renamed.', POPUP_TYPE.TEXT); } finally { - hideLoader(); + loader && hideLoader(); + } +} + +/** + * Renames the currently selected chat. + * @param {string} oldFileName Old name of the chat (no JSONL extension) + * @param {string} newName New name for the chat (no JSONL extension) + */ +export async function renameChat(oldFileName, newName) { + return await renameGroupOrCharacterChat({ + characterId: this_chid, + groupId: selected_group, + oldFileName: oldFileName, + newFileName: newName, + loader: true, + }); +} + +/** + * Forces the update of the chat name for a remote character. + * @param {string|number} characterId Character ID to update chat name for + * @param {string} newName New name for the chat + * @returns {Promise} + */ +export async function updateRemoteChatName(characterId, newName) { + const character = characters[characterId]; + if (!character) { + console.warn(`Character not found for ID: ${characterId}`); + return; + } + character.chat = newName; + const mergeRequest = { + avatar: character.avatar, + chat: newName, + }; + const mergeResponse = await fetch('/api/characters/merge-attributes', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(mergeRequest), + }); + if (!mergeResponse.ok) { + console.error('Failed to save extension field', mergeResponse.statusText); } } diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index 7bc24a717..34da82145 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -1966,6 +1966,51 @@ export async function renameGroupChat(groupId, oldChatId, newChatId) { await editGroup(groupId, true, true); } +/** + * Deletes a group chat by its name. Doesn't affect displayed chat. + * @param {string} groupId Group ID + * @param {string} chatName Name of the chat to delete + * @returns {Promise} + */ +export async function deleteGroupChatByName(groupId, chatName) { + const group = groups.find(x => x.id === groupId); + if (!group || !group.chats.includes(chatName)) { + return; + } + + if (typeof group.past_metadata !== 'object') { + group.past_metadata = {}; + } + + group.chats.splice(group.chats.indexOf(chatName), 1); + delete group.past_metadata[chatName]; + + const response = await fetch('/api/chats/group/delete', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ id: chatName }), + }); + + if (!response.ok) { + toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group chat could not be deleted`); + console.error('Group chat could not be deleted'); + return; + } + + // If the deleted chat was the current chat, switch to the last chat in the group + if (group.chat_id === chatName) { + group.chat_id = ''; + group.chat_metadata = {}; + + const newChatName = group.chats.length ? group.chats[group.chats.length - 1] : humanizedDateTime(); + group.chat_id = newChatName; + group.chat_metadata = group.past_metadata[newChatName] || {}; + } + + await editGroup(groupId, true, true); + await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatName); +} + export async function deleteGroupChat(groupId, chatId) { const group = groups.find(x => x.id === groupId); diff --git a/public/scripts/templates/welcomePanel.html b/public/scripts/templates/welcomePanel.html index 69931c4c2..e38cf8cb1 100644 --- a/public/scripts/templates/welcomePanel.html +++ b/public/scripts/templates/welcomePanel.html @@ -55,6 +55,14 @@ {{chat_name}} {{date_short}} +
+ + +
diff --git a/public/scripts/welcome-screen.js b/public/scripts/welcome-screen.js index 00a3e50ef..74e671f38 100644 --- a/public/scripts/welcome-screen.js +++ b/public/scripts/welcome-screen.js @@ -2,6 +2,7 @@ import { addOneMessage, characters, chat, + deleteCharacterChatByName, displayVersion, doNewChat, event_types, @@ -16,15 +17,18 @@ import { newAssistantChat, openCharacterChat, printCharactersDebounced, + renameGroupOrCharacterChat, selectCharacterById, system_avatar, system_message_types, this_chid, unshallowCharacter, + updateRemoteChatName, } from '../script.js'; import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; -import { getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js'; +import { deleteGroupChatByName, getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js'; import { t } from './i18n.js'; +import { callGenericPopup, POPUP_TYPE } from './popup.js'; import { getMessageTimeStamp } from './RossAscends-mods.js'; import { renderTemplateAsync } from './templates.js'; import { accountStorage } from './util/AccountStorage.js'; @@ -51,9 +55,16 @@ export function getPermanentAssistantAvatar() { return assistantAvatar; } -export async function openWelcomeScreen() { +/** + * Opens a welcome screen if no chat is currently active. + * @param {object} param Additional parameters + * @param {boolean} [param.force] If true, forces clearing of the welcome screen. + * @param {boolean} [param.expand] If true, expands the recent chats section. + * @returns {Promise} + */ +export async function openWelcomeScreen({ force = false, expand = false } = {}) { const currentChatId = getCurrentChatId(); - if (currentChatId !== undefined || chat.length > 0) { + if (currentChatId !== undefined || (chat.length > 0 && !force)) { return; } @@ -64,7 +75,13 @@ export async function openWelcomeScreen() { return; } - await sendWelcomePanel(recentChats); + if (chatAfterFetch === undefined && force) { + console.debug('Forcing welcome screen open.'); + chat.splice(0, chat.length); + $('#chat').empty(); + } + + await sendWelcomePanel(recentChats, expand); await unshallowPermanentAssistant(); sendAssistantMessage(); sendWelcomePrompt(); @@ -131,8 +148,9 @@ function sendWelcomePrompt() { /** * Sends the welcome panel to the chat. * @param {RecentChat[]} chats List of recent chats + * @param {boolean} [expand=false] If true, expands the recent chats section */ -async function sendWelcomePanel(chats) { +async function sendWelcomePanel(chats, expand = false) { try { const chatElement = document.getElementById('chat'); const sendTextArea = document.getElementById('send_textarea'); @@ -215,7 +233,50 @@ async function sendWelcomePanel(chats) { $(avatar).replaceWith(groupAvatar); } }); + fragment.querySelectorAll('.recentChat .renameChat').forEach((renameButton) => { + renameButton.addEventListener('click', (event) => { + event.stopPropagation(); + const chatItem = renameButton.closest('.recentChat'); + if (!chatItem) { + return; + } + const avatarId = chatItem.getAttribute('data-avatar'); + const groupId = chatItem.getAttribute('data-group'); + const fileName = chatItem.getAttribute('data-file'); + if (avatarId && fileName) { + void renameRecentCharacterChat(avatarId, fileName); + } + if (groupId && fileName) { + void renameRecentGroupChat(groupId, fileName); + } + }); + }); + fragment.querySelectorAll('.recentChat .deleteChat').forEach((deleteButton) => { + deleteButton.addEventListener('click', (event) => { + event.stopPropagation(); + const chatItem = deleteButton.closest('.recentChat'); + if (!chatItem) { + return; + } + const avatarId = chatItem.getAttribute('data-avatar'); + const groupId = chatItem.getAttribute('data-group'); + const fileName = chatItem.getAttribute('data-file'); + if (avatarId && fileName) { + void deleteRecentCharacterChat(avatarId, fileName); + } + if (groupId && fileName) { + void deleteRecentGroupChat(groupId, fileName); + } + }); + }); chatElement.append(fragment.firstChild); + if (expand) { + chatElement.querySelectorAll('button.showMoreChats').forEach((button) => { + if (button instanceof HTMLButtonElement) { + button.click(); + } + }); + } } catch (error) { console.error('Welcome screen error:', error); } @@ -273,6 +334,144 @@ async function openRecentGroupChat(groupId, fileName) { } } +/** + * Renames a recent character chat. + * @param {string} avatarId Avatar file name + * @param {string} fileName Chat file name + */ +async function renameRecentCharacterChat(avatarId, fileName) { + const characterId = characters.findIndex(x => x.avatar === avatarId); + if (characterId === -1) { + console.error(`Character not found for avatar ID: ${avatarId}`); + return; + } + try { + const popupText = await renderTemplateAsync('chatRename'); + const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, fileName); + if (!newName || typeof newName !== 'string' || newName === fileName) { + console.log('No new name provided, aborting'); + return; + } + await renameGroupOrCharacterChat({ + characterId: String(characterId), + oldFileName: fileName, + newFileName: newName, + loader: false, + }); + await updateRemoteChatName(characterId, newName); + await refreshWelcomeScreen(); + toastr.success(t`Chat renamed.`); + } catch (error) { + console.error('Error renaming recent character chat:', error); + toastr.error(t`Failed to rename recent chat. See console for details.`); + } +} + +/** + * Renames a recent group chat. + * @param {string} groupId Group ID + * @param {string} fileName Chat file name + */ +async function renameRecentGroupChat(groupId, fileName) { + const group = groups.find(x => x.id === groupId); + if (!group) { + console.error(`Group not found for ID: ${groupId}`); + return; + } + try { + const popupText = await renderTemplateAsync('chatRename'); + const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, fileName); + if (!newName || newName === fileName) { + console.log('No new name provided, aborting'); + return; + } + await renameGroupOrCharacterChat({ + groupId: String(groupId), + oldFileName: fileName, + newFileName: String(newName), + loader: false, + }); + await refreshWelcomeScreen(); + toastr.success(t`Group chat renamed.`); + } catch (error) { + console.error('Error renaming recent group chat:', error); + toastr.error(t`Failed to rename recent group chat. See console for details.`); + } +} + +/** + * Deletes a recent character chat. + * @param {string} avatarId Avatar file name + * @param {string} fileName Chat file name + */ +async function deleteRecentCharacterChat(avatarId, fileName) { + const characterId = characters.findIndex(x => x.avatar === avatarId); + if (characterId === -1) { + console.error(`Character not found for avatar ID: ${avatarId}`); + return; + } + try { + const confirm = await callGenericPopup(t`Delete the Chat File?`, POPUP_TYPE.CONFIRM); + if (!confirm) { + console.log('Deletion cancelled by user'); + return; + } + await deleteCharacterChatByName(String(characterId), fileName); + await refreshWelcomeScreen(); + toastr.success(t`Chat deleted.`); + } catch (error) { + console.error('Error deleting recent character chat:', error); + toastr.error(t`Failed to delete recent chat. See console for details.`); + } +} + +/** + * Deletes a recent group chat. + * @param {string} groupId Group ID + * @param {string} fileName Chat file name + */ +async function deleteRecentGroupChat(groupId, fileName) { + const group = groups.find(x => x.id === groupId); + if (!group) { + console.error(`Group not found for ID: ${groupId}`); + return; + } + try { + const confirm = await callGenericPopup(t`Delete the Chat File?`, POPUP_TYPE.CONFIRM); + if (!confirm) { + console.log('Deletion cancelled by user'); + return; + } + await deleteGroupChatByName(groupId, fileName); + await refreshWelcomeScreen(); + toastr.success(t`Group chat deleted.`); + } catch (error) { + console.error('Error deleting recent group chat:', error); + toastr.error(t`Failed to delete recent group chat. See console for details.`); + } +} + +/** + * Reopens the welcome screen and restores the scroll position. + * @returns {Promise} + */ +async function refreshWelcomeScreen() { + const chatElement = document.getElementById('chat'); + if (!chatElement) { + console.error('Chat element not found'); + return; + } + + const scrollTop = chatElement.scrollTop; + const scrollHeight = chatElement.scrollHeight; + const expand = chatElement.querySelectorAll('button.showMoreChats.rotated').length > 0; + + await openWelcomeScreen({ force: true, expand }); + + // Restore scroll position + chatElement.scrollTop = scrollTop + (chatElement.scrollHeight - scrollHeight); +} + /** * Gets the list of recent chats from the server. * @returns {Promise} List of recent chats