diff --git a/public/scripts/templates/welcomePanel.html b/public/scripts/templates/welcomePanel.html index ffe4238ce..e428f2fdd 100644 --- a/public/scripts/templates/welcomePanel.html +++ b/public/scripts/templates/welcomePanel.html @@ -37,7 +37,7 @@ {{/if}} {{#each chats}} {{#with this}} -
+
{{char_name}}
diff --git a/public/scripts/welcome-screen.js b/public/scripts/welcome-screen.js index 912323a8f..6ad9a22a0 100644 --- a/public/scripts/welcome-screen.js +++ b/public/scripts/welcome-screen.js @@ -21,11 +21,11 @@ import { system_message_types, this_chid, } from '../script.js'; -import { is_group_generating } from './group-chats.js'; +import { getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js'; import { t } from './i18n.js'; import { renderTemplateAsync } from './templates.js'; import { accountStorage } from './util/AccountStorage.js'; -import { timestampToMoment } from './utils.js'; +import { sortMoments, timestampToMoment } from './utils.js'; const assistantAvatarKey = 'assistant'; const defaultAssistantAvatar = 'default_Assistant.png'; @@ -94,9 +94,13 @@ async function sendWelcomePanel() { fragment.querySelectorAll('.recentChat').forEach((item) => { item.addEventListener('click', () => { const avatarId = item.getAttribute('data-avatar'); + const groupId = item.getAttribute('data-group'); const fileName = item.getAttribute('data-file'); if (avatarId && fileName) { - void openRecentChat(avatarId, fileName); + void openRecentCharacterChat(avatarId, fileName); + } + if (groupId && fileName) { + void openRecentGroupChat(groupId, fileName); } }); }); @@ -114,6 +118,18 @@ async function sendWelcomePanel() { void newAssistantChat({ temporary: true }); }); }); + fragment.querySelectorAll('.recentChat.group').forEach((groupChat) => { + const groupId = groupChat.getAttribute('data-group'); + const group = groups.find(x => x.id === groupId); + if (group) { + const avatar = groupChat.querySelector('.avatar'); + if (!avatar) { + return; + } + const groupAvatar = getGroupAvatar(group); + $(avatar).replaceWith(groupAvatar); + } + }); chatElement.append(fragment.firstChild); } catch (error) { console.error('Welcome screen error:', error); @@ -121,11 +137,11 @@ async function sendWelcomePanel() { } /** - * Opens a recent chat. + * Opens a recent character chat. * @param {string} avatarId Avatar file name * @param {string} fileName Chat file name */ -async function openRecentChat(avatarId, fileName) { +async function openRecentCharacterChat(avatarId, fileName) { const characterId = characters.findIndex(x => x.avatar === avatarId); if (characterId === -1) { console.error(`Character not found for avatar ID: ${avatarId}`); @@ -146,6 +162,32 @@ async function openRecentChat(avatarId, fileName) { } } +/** + * Opens a recent group chat. + * @param {string} groupId Group ID + * @param {string} fileName Chat file name + */ +async function openRecentGroupChat(groupId, fileName) { + const group = groups.find(x => x.id === groupId); + if (!group) { + console.error(`Group not found for ID: ${groupId}`); + return; + } + + try { + await openGroupById(groupId); + const currentChatId = getCurrentChatId(); + if (currentChatId === fileName) { + console.debug(`Chat ${fileName} is already open.`); + return; + } + await openGroupChat(groupId, fileName); + } catch (error) { + console.error('Error opening recent group chat:', error); + toastr.error(t`Failed to open recent group chat. See console for details.`); + } +} + /** * Gets the list of recent chats from the server. * @returns {Promise} List of recent chats @@ -156,37 +198,56 @@ async function openRecentChat(avatarId, fileName) { * @property {string} file_size Size of the chat file * @property {number} chat_items Number of items in the chat * @property {string} mes Last message content - * @property {number} last_mes Timestamp of the last message + * @property {string} last_mes Timestamp of the last message * @property {string} avatar Avatar URL * @property {string} char_thumbnail Thumbnail URL - * @property {string} char_name Character name + * @property {string} char_name Character or group name * @property {string} date_short Date in short format * @property {string} date_long Date in long format + * @property {string} group Group ID (if applicable) + * @property {boolean} is_group Indicates if the chat is a group chat * @property {boolean} hidden Chat will be hidden by default */ async function getRecentChats() { - const response = await fetch('/api/characters/recent', { - method: 'POST', - headers: getRequestHeaders(), - }); - if (!response.ok) { - throw new Error('Failed to fetch recent chats'); - } + const charData = async () => { + const response = await fetch('/api/characters/recent', { + method: 'POST', + headers: getRequestHeaders(), + }); + if (!response.ok) { + console.warn('Failed to fetch recent character chats'); + return []; + } + return await response.json(); + }; + + const groupData = async () => { + const response = await fetch('/api/groups/recent', { + method: 'POST', + headers: getRequestHeaders(), + }); + if (!response.ok) { + console.warn('Failed to fetch recent group chats'); + return []; + } + return await response.json(); + }; /** @type {RecentChat[]} */ - const data = await response.json(); + const data = [...await charData(), ...await groupData()]; - data.sort((a, b) => b.last_mes - a.last_mes) - .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar) })) - .filter(t => t.character) - .forEach(({ chat, character }, index) => { + data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))) + .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) })) + .filter(t => t.character || t.group) + .forEach(({ chat, character, group }, index) => { const DEFAULT_DISPLAYED = 5; const chatTimestamp = timestampToMoment(chat.last_mes); - chat.char_name = character.name; + chat.char_name = character?.name || group?.name || ''; chat.date_short = chatTimestamp.format('l'); chat.date_long = chatTimestamp.format('LL LT'); chat.chat_name = chat.file_name.replace('.jsonl', ''); - chat.char_thumbnail = getThumbnailUrl('avatar', character.avatar); + chat.char_thumbnail = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar; + chat.is_group = !!group; chat.hidden = index >= DEFAULT_DISPLAYED; }); diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js index 0ea9caf12..8170b3c6a 100644 --- a/src/endpoints/characters.js +++ b/src/endpoints/characters.js @@ -949,7 +949,7 @@ async function importFromPng(uploadPath, { request }, preservedFileName) { * @param {object} additionalData * @returns {Promise} */ -async function getChatInfo(pathToFile, additionalData = {}) { +export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false) { return new Promise(async (res) => { const fileStream = fs.createReadStream(pathToFile); const stats = fs.statSync(pathToFile); @@ -982,9 +982,9 @@ async function getChatInfo(pathToFile, additionalData = {}) { chatData['file_name'] = path.parse(pathToFile).base; chatData['file_size'] = fileSizeInKB; - chatData['chat_items'] = itemCounter - 1; + chatData['chat_items'] = isGroup ? itemCounter : (itemCounter - 1); chatData['mes'] = jsonData['mes'] || '[The chat is empty]'; - chatData['last_mes'] = jsonData['send_date'] || Date.now(); + chatData['last_mes'] = jsonData['send_date'] || stats.mtimeMs; Object.assign(chatData, additionalData); res(chatData); diff --git a/src/endpoints/groups.js b/src/endpoints/groups.js index 469bddba6..cdbabae7b 100644 --- a/src/endpoints/groups.js +++ b/src/endpoints/groups.js @@ -6,6 +6,7 @@ import sanitize from 'sanitize-filename'; import { sync as writeFileAtomicSync } from 'write-file-atomic'; import { humanizedISO8601DateTime } from '../util.js'; +import { getChatInfo } from './characters.js'; export const router = express.Router(); @@ -131,3 +132,41 @@ router.post('/delete', async (request, response) => { return response.send({ ok: true }); }); + +router.post('/recent', async (request, response) => { + try { + /** @type {{groupId: string, filePath: string, mtime: number}[]} */ + const allChatFiles = []; + + const groups = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json'); + for (const group of groups) { + const groupPath = path.join(request.user.directories.groups, group); + const groupContents = fs.readFileSync(groupPath, 'utf8'); + const groupData = JSON.parse(groupContents); + + if (Array.isArray(groupData.chats)) { + for (const chat of groupData.chats) { + const filePath = path.join(request.user.directories.groupChats, `${chat}.jsonl`); + if (!fs.existsSync(filePath)) { + continue; + } + const stats = fs.statSync(filePath); + allChatFiles.push({ groupId: groupData.id, filePath, mtime: stats.mtimeMs }); + } + } + } + + const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, 15); + const jsonFilesPromise = recentChats.map((file) => { + return getChatInfo(file.filePath, { group: file.groupId }, true); + }); + + const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); + const validFiles = chatData.filter(i => i.file_name); + + return response.send(validFiles); + } catch (error) { + console.error('Error while getting recent group chats', error); + return response.sendStatus(500); + } +});