Show recent group chats
| @@ -37,7 +37,7 @@ | |||
| 37 | {{/if}} | 37 | {{/if}} |
| 38 | {{#each chats}} | 38 | {{#each chats}} |
| 39 | {{#with this}} | 39 | {{#with this}} |
| 40 | <div class="recentChat {{#if hidden}}hidden{{/if}}" data-file="{{chat_name}}" data-avatar="{{avatar}}"> | 40 | <div class="recentChat {{#if hidden}}hidden{{/if}} {{#if is_group}}group{{/if}}" data-file="{{chat_name}}" data-avatar="{{avatar}}" data-group="{{group}}"> |
| 41 | <div class="avatar" title="{{char_name}}"> | 41 | <div class="avatar" title="{{char_name}}"> |
| 42 | <img src="{{char_thumbnail}}" alt="{{char_name}}"> | 42 | <img src="{{char_thumbnail}}" alt="{{char_name}}"> |
| 43 | </div> | 43 | </div> |
| @@ -21,11 +21,11 @@ import { | |||
| 21 | system_message_types, | 21 | system_message_types, |
| 22 | this_chid, | 22 | this_chid, |
| 23 | } from '../script.js'; | 23 | } from '../script.js'; |
| 24 | import { is_group_generating } from './group-chats.js'; | 24 | import { getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js'; |
| 25 | import { t } from './i18n.js'; | 25 | import { t } from './i18n.js'; |
| 26 | import { renderTemplateAsync } from './templates.js'; | 26 | import { renderTemplateAsync } from './templates.js'; |
| 27 | import { accountStorage } from './util/AccountStorage.js'; | 27 | import { accountStorage } from './util/AccountStorage.js'; |
| 28 | import { timestampToMoment } from './utils.js'; | 28 | import { sortMoments, timestampToMoment } from './utils.js'; |
| 29 | 29 | ||
| 30 | const assistantAvatarKey = 'assistant'; | 30 | const assistantAvatarKey = 'assistant'; |
| 31 | const defaultAssistantAvatar = 'default_Assistant.png'; | 31 | const defaultAssistantAvatar = 'default_Assistant.png'; |
| @@ -94,9 +94,13 @@ async function sendWelcomePanel() { | |||
| 94 | fragment.querySelectorAll('.recentChat').forEach((item) => { | 94 | fragment.querySelectorAll('.recentChat').forEach((item) => { |
| 95 | item.addEventListener('click', () => { | 95 | item.addEventListener('click', () => { |
| 96 | const avatarId = item.getAttribute('data-avatar'); | 96 | const avatarId = item.getAttribute('data-avatar'); |
| 97 | const groupId = item.getAttribute('data-group'); | ||
| 97 | const fileName = item.getAttribute('data-file'); | 98 | const fileName = item.getAttribute('data-file'); |
| 98 | if (avatarId && fileName) { | 99 | if (avatarId && fileName) { |
| 99 | void openRecentChat(avatarId, fileName); | 100 | void openRecentCharacterChat(avatarId, fileName); |
| 101 | } | ||
| 102 | if (groupId && fileName) { | ||
| 103 | void openRecentGroupChat(groupId, fileName); | ||
| 100 | } | 104 | } |
| 101 | }); | 105 | }); |
| 102 | }); | 106 | }); |
| @@ -114,6 +118,18 @@ async function sendWelcomePanel() { | |||
| 114 | void newAssistantChat({ temporary: true }); | 118 | void newAssistantChat({ temporary: true }); |
| 115 | }); | 119 | }); |
| 116 | }); | 120 | }); |
| 121 | fragment.querySelectorAll('.recentChat.group').forEach((groupChat) => { | ||
| 122 | const groupId = groupChat.getAttribute('data-group'); | ||
| 123 | const group = groups.find(x => x.id === groupId); | ||
| 124 | if (group) { | ||
| 125 | const avatar = groupChat.querySelector('.avatar'); | ||
| 126 | if (!avatar) { | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | const groupAvatar = getGroupAvatar(group); | ||
| 130 | $(avatar).replaceWith(groupAvatar); | ||
| 131 | } | ||
| 132 | }); | ||
| 117 | chatElement.append(fragment.firstChild); | 133 | chatElement.append(fragment.firstChild); |
| 118 | } catch (error) { | 134 | } catch (error) { |
| 119 | console.error('Welcome screen error:', error); | 135 | console.error('Welcome screen error:', error); |
| @@ -121,11 +137,11 @@ async function sendWelcomePanel() { | |||
| 121 | } | 137 | } |
| 122 | 138 | ||
| 123 | /** | 139 | /** |
| 124 | * Opens a recent chat. | 140 | * Opens a recent character chat. |
| 125 | * @param {string} avatarId Avatar file name | 141 | * @param {string} avatarId Avatar file name |
| 126 | * @param {string} fileName Chat file name | 142 | * @param {string} fileName Chat file name |
| 127 | */ | 143 | */ |
| 128 | async function openRecentChat(avatarId, fileName) { | 144 | async function openRecentCharacterChat(avatarId, fileName) { |
| 129 | const characterId = characters.findIndex(x => x.avatar === avatarId); | 145 | const characterId = characters.findIndex(x => x.avatar === avatarId); |
| 130 | if (characterId === -1) { | 146 | if (characterId === -1) { |
| 131 | console.error(`Character not found for avatar ID: ${avatarId}`); | 147 | console.error(`Character not found for avatar ID: ${avatarId}`); |
| @@ -147,6 +163,32 @@ async function openRecentChat(avatarId, fileName) { | |||
| 147 | } | 163 | } |
| 148 | 164 | ||
| 149 | /** | 165 | /** |
| 166 | * Opens a recent group chat. | ||
| 167 | * @param {string} groupId Group ID | ||
| 168 | * @param {string} fileName Chat file name | ||
| 169 | */ | ||
| 170 | async function openRecentGroupChat(groupId, fileName) { | ||
| 171 | const group = groups.find(x => x.id === groupId); | ||
| 172 | if (!group) { | ||
| 173 | console.error(`Group not found for ID: ${groupId}`); | ||
| 174 | return; | ||
| 175 | } | ||
| 176 | |||
| 177 | try { | ||
| 178 | await openGroupById(groupId); | ||
| 179 | const currentChatId = getCurrentChatId(); | ||
| 180 | if (currentChatId === fileName) { | ||
| 181 | console.debug(`Chat ${fileName} is already open.`); | ||
| 182 | return; | ||
| 183 | } | ||
| 184 | await openGroupChat(groupId, fileName); | ||
| 185 | } catch (error) { | ||
| 186 | console.error('Error opening recent group chat:', error); | ||
| 187 | toastr.error(t`Failed to open recent group chat. See console for details.`); | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | /** | ||
| 150 | * Gets the list of recent chats from the server. | 192 | * Gets the list of recent chats from the server. |
| 151 | * @returns {Promise<RecentChat[]>} List of recent chats | 193 | * @returns {Promise<RecentChat[]>} List of recent chats |
| 152 | * | 194 | * |
| @@ -156,37 +198,56 @@ async function openRecentChat(avatarId, fileName) { | |||
| 156 | * @property {string} file_size Size of the chat file | 198 | * @property {string} file_size Size of the chat file |
| 157 | * @property {number} chat_items Number of items in the chat | 199 | * @property {number} chat_items Number of items in the chat |
| 158 | * @property {string} mes Last message content | 200 | * @property {string} mes Last message content |
| 159 | * @property {number} last_mes Timestamp of the last message | 201 | * @property {string} last_mes Timestamp of the last message |
| 160 | * @property {string} avatar Avatar URL | 202 | * @property {string} avatar Avatar URL |
| 161 | * @property {string} char_thumbnail Thumbnail URL | 203 | * @property {string} char_thumbnail Thumbnail URL |
| 162 | * @property {string} char_name Character name | 204 | * @property {string} char_name Character or group name |
| 163 | * @property {string} date_short Date in short format | 205 | * @property {string} date_short Date in short format |
| 164 | * @property {string} date_long Date in long format | 206 | * @property {string} date_long Date in long format |
| 207 | * @property {string} group Group ID (if applicable) | ||
| 208 | * @property {boolean} is_group Indicates if the chat is a group chat | ||
| 165 | * @property {boolean} hidden Chat will be hidden by default | 209 | * @property {boolean} hidden Chat will be hidden by default |
| 166 | */ | 210 | */ |
| 167 | async function getRecentChats() { | 211 | async function getRecentChats() { |
| 212 | const charData = async () => { | ||
| 168 | const response = await fetch('/api/characters/recent', { | 213 | const response = await fetch('/api/characters/recent', { |
| 169 | method: 'POST', | 214 | method: 'POST', |
| 170 | headers: getRequestHeaders(), | 215 | headers: getRequestHeaders(), |
| 171 | }); | 216 | }); |
| 172 | if (!response.ok) { | 217 | if (!response.ok) { |
| 173 | throw new Error('Failed to fetch recent chats'); | 218 | console.warn('Failed to fetch recent character chats'); |
| 219 | return []; | ||
| 220 | } | ||
| 221 | return await response.json(); | ||
| 222 | }; | ||
| 223 | |||
| 224 | const groupData = async () => { | ||
| 225 | const response = await fetch('/api/groups/recent', { | ||
| 226 | method: 'POST', | ||
| 227 | headers: getRequestHeaders(), | ||
| 228 | }); | ||
| 229 | if (!response.ok) { | ||
| 230 | console.warn('Failed to fetch recent group chats'); | ||
| 231 | return []; | ||
| 174 | } | 232 | } |
| 233 | return await response.json(); | ||
| 234 | }; | ||
| 175 | 235 | ||
| 176 | /** @type {RecentChat[]} */ | 236 | /** @type {RecentChat[]} */ |
| 177 | const data = await response.json(); | 237 | const data = [...await charData(), ...await groupData()]; |
| 178 | 238 | ||
| 179 | data.sort((a, b) => b.last_mes - a.last_mes) | 239 | data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))) |
| 180 | .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar) })) | 240 | .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) })) |
| 181 | .filter(t => t.character) | 241 | .filter(t => t.character || t.group) |
| 182 | .forEach(({ chat, character }, index) => { | 242 | .forEach(({ chat, character, group }, index) => { |
| 183 | const DEFAULT_DISPLAYED = 5; | 243 | const DEFAULT_DISPLAYED = 5; |
| 184 | const chatTimestamp = timestampToMoment(chat.last_mes); | 244 | const chatTimestamp = timestampToMoment(chat.last_mes); |
| 185 | chat.char_name = character.name; | 245 | chat.char_name = character?.name || group?.name || ''; |
| 186 | chat.date_short = chatTimestamp.format('l'); | 246 | chat.date_short = chatTimestamp.format('l'); |
| 187 | chat.date_long = chatTimestamp.format('LL LT'); | 247 | chat.date_long = chatTimestamp.format('LL LT'); |
| 188 | chat.chat_name = chat.file_name.replace('.jsonl', ''); | 248 | chat.chat_name = chat.file_name.replace('.jsonl', ''); |
| 189 | chat.char_thumbnail = getThumbnailUrl('avatar', character.avatar); | 249 | chat.char_thumbnail = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar; |
| 250 | chat.is_group = !!group; | ||
| 190 | chat.hidden = index >= DEFAULT_DISPLAYED; | 251 | chat.hidden = index >= DEFAULT_DISPLAYED; |
| 191 | }); | 252 | }); |
| 192 | 253 | ||
| @@ -949,7 +949,7 @@ async function importFromPng(uploadPath, { request }, preservedFileName) { | |||
| 949 | * @param {object} additionalData | 949 | * @param {object} additionalData |
| 950 | * @returns {Promise<ChatInfo>} | 950 | * @returns {Promise<ChatInfo>} |
| 951 | */ | 951 | */ |
| 952 | async function getChatInfo(pathToFile, additionalData = {}) { | 952 | export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false) { |
| 953 | return new Promise(async (res) => { | 953 | return new Promise(async (res) => { |
| 954 | const fileStream = fs.createReadStream(pathToFile); | 954 | const fileStream = fs.createReadStream(pathToFile); |
| 955 | const stats = fs.statSync(pathToFile); | 955 | const stats = fs.statSync(pathToFile); |
| @@ -982,9 +982,9 @@ async function getChatInfo(pathToFile, additionalData = {}) { | |||
| 982 | 982 | ||
| 983 | chatData['file_name'] = path.parse(pathToFile).base; | 983 | chatData['file_name'] = path.parse(pathToFile).base; |
| 984 | chatData['file_size'] = fileSizeInKB; | 984 | chatData['file_size'] = fileSizeInKB; |
| 985 | chatData['chat_items'] = itemCounter - 1; | 985 | chatData['chat_items'] = isGroup ? itemCounter : (itemCounter - 1); |
| 986 | chatData['mes'] = jsonData['mes'] || '[The chat is empty]'; | 986 | chatData['mes'] = jsonData['mes'] || '[The chat is empty]'; |
| 987 | chatData['last_mes'] = jsonData['send_date'] || Date.now(); | 987 | chatData['last_mes'] = jsonData['send_date'] || stats.mtimeMs; |
| 988 | Object.assign(chatData, additionalData); | 988 | Object.assign(chatData, additionalData); |
| 989 | 989 | ||
| 990 | res(chatData); | 990 | res(chatData); |
| @@ -6,6 +6,7 @@ import sanitize from 'sanitize-filename'; | |||
| 6 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; | 6 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 7 | 7 | ||
| 8 | import { humanizedISO8601DateTime } from '../util.js'; | 8 | import { humanizedISO8601DateTime } from '../util.js'; |
| 9 | import { getChatInfo } from './characters.js'; | ||
| 9 | 10 | ||
| 10 | export const router = express.Router(); | 11 | export const router = express.Router(); |
| 11 | 12 | ||
| @@ -131,3 +132,41 @@ router.post('/delete', async (request, response) => { | |||
| 131 | 132 | ||
| 132 | return response.send({ ok: true }); | 133 | return response.send({ ok: true }); |
| 133 | }); | 134 | }); |
| 135 | |||
| 136 | router.post('/recent', async (request, response) => { | ||
| 137 | try { | ||
| 138 | /** @type {{groupId: string, filePath: string, mtime: number}[]} */ | ||
| 139 | const allChatFiles = []; | ||
| 140 | |||
| 141 | const groups = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json'); | ||
| 142 | for (const group of groups) { | ||
| 143 | const groupPath = path.join(request.user.directories.groups, group); | ||
| 144 | const groupContents = fs.readFileSync(groupPath, 'utf8'); | ||
| 145 | const groupData = JSON.parse(groupContents); | ||
| 146 | |||
| 147 | if (Array.isArray(groupData.chats)) { | ||
| 148 | for (const chat of groupData.chats) { | ||
| 149 | const filePath = path.join(request.user.directories.groupChats, `${chat}.jsonl`); | ||
| 150 | if (!fs.existsSync(filePath)) { | ||
| 151 | continue; | ||
| 152 | } | ||
| 153 | const stats = fs.statSync(filePath); | ||
| 154 | allChatFiles.push({ groupId: groupData.id, filePath, mtime: stats.mtimeMs }); | ||
| 155 | } | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, 15); | ||
| 160 | const jsonFilesPromise = recentChats.map((file) => { | ||
| 161 | return getChatInfo(file.filePath, { group: file.groupId }, true); | ||
| 162 | }); | ||
| 163 | |||
| 164 | const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); | ||
| 165 | const validFiles = chatData.filter(i => i.file_name); | ||
| 166 | |||
| 167 | return response.send(validFiles); | ||
| 168 | } catch (error) { | ||
| 169 | console.error('Error while getting recent group chats', error); | ||
| 170 | return response.sendStatus(500); | ||
| 171 | } | ||
| 172 | }); | ||