Join recent chat/group queries
| @@ -31,7 +31,7 @@ const assistantAvatarKey = 'assistant'; | |||
| 31 | const defaultAssistantAvatar = 'default_Assistant.png'; | 31 | const defaultAssistantAvatar = 'default_Assistant.png'; |
| 32 | 32 | ||
| 33 | const DEFAULT_DISPLAYED = 3; | 33 | const DEFAULT_DISPLAYED = 3; |
| 34 | const MAX_DISPLAYED = 20; | 34 | const MAX_DISPLAYED = 15; |
| 35 | 35 | ||
| 36 | export function getPermanentAssistantAvatar() { | 36 | export function getPermanentAssistantAvatar() { |
| 37 | const assistantAvatar = accountStorage.getItem(assistantAvatarKey); | 37 | const assistantAvatar = accountStorage.getItem(assistantAvatarKey); |
| @@ -247,32 +247,19 @@ async function openRecentGroupChat(groupId, fileName) { | |||
| 247 | * @property {boolean} hidden Chat will be hidden by default | 247 | * @property {boolean} hidden Chat will be hidden by default |
| 248 | */ | 248 | */ |
| 249 | async function getRecentChats() { | 249 | async function getRecentChats() { |
| 250 | const charData = async () => { | 250 | const response = await fetch('/api/chats/recent', { |
| 251 | const response = await fetch('/api/characters/recent', { | ||
| 252 | method: 'POST', | 251 | method: 'POST', |
| 253 | headers: getRequestHeaders(), | 252 | headers: getRequestHeaders(), |
| 253 | body: JSON.stringify({ max: MAX_DISPLAYED }), | ||
| 254 | }); | 254 | }); |
| 255 | if (!response.ok) { | ||
| 256 | console.warn('Failed to fetch recent character chats'); | ||
| 257 | return []; | ||
| 258 | } | ||
| 259 | return await response.json(); | ||
| 260 | }; | ||
| 261 | 255 | ||
| 262 | const groupData = async () => { | ||
| 263 | const response = await fetch('/api/groups/recent', { | ||
| 264 | method: 'POST', | ||
| 265 | headers: getRequestHeaders(), | ||
| 266 | }); | ||
| 267 | if (!response.ok) { | 256 | if (!response.ok) { |
| 268 | console.warn('Failed to fetch recent group chats'); | 257 | console.warn('Failed to fetch recent character chats'); |
| 269 | return []; | 258 | return []; |
| 270 | } | 259 | } |
| 271 | return await response.json(); | ||
| 272 | }; | ||
| 273 | 260 | ||
| 274 | /** @type {RecentChat[]} */ | 261 | /** @type {RecentChat[]} */ |
| 275 | const data = await Promise.all([charData(), groupData()]).then(res => res.flat()); | 262 | const data = await response.json(); |
| 276 | 263 | ||
| 277 | data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))) | 264 | data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))) |
| 278 | .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) })) | 265 | .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) })) |
| @@ -290,7 +277,7 @@ async function getRecentChats() { | |||
| 290 | chat.group = chat.group || ''; | 277 | chat.group = chat.group || ''; |
| 291 | }); | 278 | }); |
| 292 | 279 | ||
| 293 | return data.slice(0, MAX_DISPLAYED); | 280 | return data; |
| 294 | } | 281 | } |
| 295 | 282 | ||
| 296 | export async function openPermanentAssistantChat({ tryCreate = true, created = false } = {}) { | 283 | export async function openPermanentAssistantChat({ tryCreate = true, created = false } = {}) { |
| @@ -1,7 +1,6 @@ | |||
| 1 | import path from 'node:path'; | 1 | import path from 'node:path'; |
| 2 | import fs from 'node:fs'; | 2 | import fs from 'node:fs'; |
| 3 | import { promises as fsPromises } from 'node:fs'; | 3 | import { promises as fsPromises } from 'node:fs'; |
| 4 | import readline from 'node:readline'; | ||
| 5 | import { Buffer } from 'node:buffer'; | 4 | import { Buffer } from 'node:buffer'; |
| 6 | 5 | ||
| 7 | import express from 'express'; | 6 | import express from 'express'; |
| @@ -22,6 +21,7 @@ import { readWorldInfoFile } from './worldinfo.js'; | |||
| 22 | import { invalidateThumbnail } from './thumbnails.js'; | 21 | import { invalidateThumbnail } from './thumbnails.js'; |
| 23 | import { importRisuSprites } from './sprites.js'; | 22 | import { importRisuSprites } from './sprites.js'; |
| 24 | import { getUserDirectories } from '../users.js'; | 23 | import { getUserDirectories } from '../users.js'; |
| 24 | import { getChatInfo } from './chats.js'; | ||
| 25 | const defaultAvatarPath = './public/img/ai4.png'; | 25 | const defaultAvatarPath = './public/img/ai4.png'; |
| 26 | 26 | ||
| 27 | // With 100 MB limit it would take roughly 3000 characters to reach this limit | 27 | // With 100 MB limit it would take roughly 3000 characters to reach this limit |
| @@ -934,69 +934,6 @@ async function importFromPng(uploadPath, { request }, preservedFileName) { | |||
| 934 | return ''; | 934 | return ''; |
| 935 | } | 935 | } |
| 936 | 936 | ||
| 937 | /** | ||
| 938 | * @typedef {Object} ChatInfo | ||
| 939 | * @property {string} [file_name] - The name of the chat file | ||
| 940 | * @property {string} [file_size] - The size of the chat file | ||
| 941 | * @property {number} [chat_items] - The number of chat items in the file | ||
| 942 | * @property {string} [mes] - The last message in the chat | ||
| 943 | * @property {number} [last_mes] - The timestamp of the last message | ||
| 944 | */ | ||
| 945 | |||
| 946 | /** | ||
| 947 | * Reads the information from a chat file. | ||
| 948 | * @param {string} pathToFile | ||
| 949 | * @param {object} additionalData | ||
| 950 | * @returns {Promise<ChatInfo>} | ||
| 951 | */ | ||
| 952 | export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false) { | ||
| 953 | return new Promise(async (res) => { | ||
| 954 | const fileStream = fs.createReadStream(pathToFile); | ||
| 955 | const stats = fs.statSync(pathToFile); | ||
| 956 | const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`; | ||
| 957 | |||
| 958 | if (stats.size === 0) { | ||
| 959 | console.warn(`Found an empty chat file: ${pathToFile}`); | ||
| 960 | res({}); | ||
| 961 | return; | ||
| 962 | } | ||
| 963 | |||
| 964 | const rl = readline.createInterface({ | ||
| 965 | input: fileStream, | ||
| 966 | crlfDelay: Infinity, | ||
| 967 | }); | ||
| 968 | |||
| 969 | let lastLine; | ||
| 970 | let itemCounter = 0; | ||
| 971 | rl.on('line', (line) => { | ||
| 972 | itemCounter++; | ||
| 973 | lastLine = line; | ||
| 974 | }); | ||
| 975 | rl.on('close', () => { | ||
| 976 | rl.close(); | ||
| 977 | |||
| 978 | if (lastLine) { | ||
| 979 | const jsonData = tryParse(lastLine); | ||
| 980 | if (jsonData && (jsonData.name || jsonData.character_name)) { | ||
| 981 | const chatData = {}; | ||
| 982 | |||
| 983 | chatData['file_name'] = path.parse(pathToFile).base; | ||
| 984 | chatData['file_size'] = fileSizeInKB; | ||
| 985 | chatData['chat_items'] = isGroup ? itemCounter : (itemCounter - 1); | ||
| 986 | chatData['mes'] = jsonData['mes'] || '[The chat is empty]'; | ||
| 987 | chatData['last_mes'] = jsonData['send_date'] || stats.mtimeMs; | ||
| 988 | Object.assign(chatData, additionalData); | ||
| 989 | |||
| 990 | res(chatData); | ||
| 991 | } else { | ||
| 992 | console.warn('Found an invalid or corrupted chat file:', pathToFile); | ||
| 993 | res({}); | ||
| 994 | } | ||
| 995 | } | ||
| 996 | }); | ||
| 997 | }); | ||
| 998 | } | ||
| 999 | |||
| 1000 | export const router = express.Router(); | 937 | export const router = express.Router(); |
| 1001 | 938 | ||
| 1002 | router.post('/create', async function (request, response) { | 939 | router.post('/create', async function (request, response) { |
| @@ -1286,47 +1223,6 @@ router.post('/get', validateAvatarUrlMiddleware, async function (request, respon | |||
| 1286 | } | 1223 | } |
| 1287 | }); | 1224 | }); |
| 1288 | 1225 | ||
| 1289 | router.post('/recent', async function (request, response) { | ||
| 1290 | try { | ||
| 1291 | /** @type {{pngFile: string, filePath: string, mtime: number}[]} */ | ||
| 1292 | const allChatFiles = []; | ||
| 1293 | |||
| 1294 | const pngFiles = fs | ||
| 1295 | .readdirSync(request.user.directories.characters, { withFileTypes: true }) | ||
| 1296 | .filter(dirent => dirent.isFile() && dirent.name.endsWith('.png')) | ||
| 1297 | .map(dirent => dirent.name); | ||
| 1298 | |||
| 1299 | for (const pngFile of pngFiles) { | ||
| 1300 | const chatsDirectory = pngFile.replace('.png', ''); | ||
| 1301 | const pathToChats = path.join(request.user.directories.chats, chatsDirectory); | ||
| 1302 | if (fs.existsSync(pathToChats) && fs.statSync(pathToChats).isDirectory()) { | ||
| 1303 | const chatFiles = fs.readdirSync(pathToChats); | ||
| 1304 | const chatFilesWithDate = chatFiles | ||
| 1305 | .filter(file => file.endsWith('.jsonl')) | ||
| 1306 | .map(file => { | ||
| 1307 | const filePath = path.join(pathToChats, file); | ||
| 1308 | const stats = fs.statSync(filePath); | ||
| 1309 | return { pngFile, filePath, mtime: stats.mtimeMs }; | ||
| 1310 | }); | ||
| 1311 | allChatFiles.push(...chatFilesWithDate); | ||
| 1312 | } | ||
| 1313 | } | ||
| 1314 | |||
| 1315 | const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, 15); | ||
| 1316 | const jsonFilesPromise = recentChats.map((file) => { | ||
| 1317 | return getChatInfo(file.filePath, { avatar: file.pngFile }); | ||
| 1318 | }); | ||
| 1319 | |||
| 1320 | const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); | ||
| 1321 | const validFiles = chatData.filter(i => i.file_name); | ||
| 1322 | |||
| 1323 | return response.send(validFiles); | ||
| 1324 | } catch (error) { | ||
| 1325 | console.error(error); | ||
| 1326 | return response.sendStatus(500); | ||
| 1327 | } | ||
| 1328 | }); | ||
| 1329 | |||
| 1330 | router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) { | 1226 | router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) { |
| 1331 | try { | 1227 | try { |
| 1332 | if (!request.body) return response.sendStatus(400); | 1228 | if (!request.body) return response.sendStatus(400); |
| @@ -351,6 +351,69 @@ async function checkChatIntegrity(filePath, integritySlug) { | |||
| 351 | return chatIntegrity === integritySlug; | 351 | return chatIntegrity === integritySlug; |
| 352 | } | 352 | } |
| 353 | 353 | ||
| 354 | /** | ||
| 355 | * @typedef {Object} ChatInfo | ||
| 356 | * @property {string} [file_name] - The name of the chat file | ||
| 357 | * @property {string} [file_size] - The size of the chat file | ||
| 358 | * @property {number} [chat_items] - The number of chat items in the file | ||
| 359 | * @property {string} [mes] - The last message in the chat | ||
| 360 | * @property {number} [last_mes] - The timestamp of the last message | ||
| 361 | */ | ||
| 362 | |||
| 363 | /** | ||
| 364 | * Reads the information from a chat file. | ||
| 365 | * @param {string} pathToFile | ||
| 366 | * @param {object} additionalData | ||
| 367 | * @returns {Promise<ChatInfo>} | ||
| 368 | */ | ||
| 369 | export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false) { | ||
| 370 | return new Promise(async (res) => { | ||
| 371 | const fileStream = fs.createReadStream(pathToFile); | ||
| 372 | const stats = await fs.promises.stat(pathToFile); | ||
| 373 | const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`; | ||
| 374 | |||
| 375 | if (stats.size === 0) { | ||
| 376 | console.warn(`Found an empty chat file: ${pathToFile}`); | ||
| 377 | res({}); | ||
| 378 | return; | ||
| 379 | } | ||
| 380 | |||
| 381 | const rl = readline.createInterface({ | ||
| 382 | input: fileStream, | ||
| 383 | crlfDelay: Infinity, | ||
| 384 | }); | ||
| 385 | |||
| 386 | let lastLine; | ||
| 387 | let itemCounter = 0; | ||
| 388 | rl.on('line', (line) => { | ||
| 389 | itemCounter++; | ||
| 390 | lastLine = line; | ||
| 391 | }); | ||
| 392 | rl.on('close', () => { | ||
| 393 | rl.close(); | ||
| 394 | |||
| 395 | if (lastLine) { | ||
| 396 | const jsonData = tryParse(lastLine); | ||
| 397 | if (jsonData && (jsonData.name || jsonData.character_name)) { | ||
| 398 | const chatData = {}; | ||
| 399 | |||
| 400 | chatData['file_name'] = path.parse(pathToFile).base; | ||
| 401 | chatData['file_size'] = fileSizeInKB; | ||
| 402 | chatData['chat_items'] = isGroup ? itemCounter : (itemCounter - 1); | ||
| 403 | chatData['mes'] = jsonData['mes'] || '[The chat is empty]'; | ||
| 404 | chatData['last_mes'] = jsonData['send_date'] || stats.mtimeMs; | ||
| 405 | Object.assign(chatData, additionalData); | ||
| 406 | |||
| 407 | res(chatData); | ||
| 408 | } else { | ||
| 409 | console.warn('Found an invalid or corrupted chat file:', pathToFile); | ||
| 410 | res({}); | ||
| 411 | } | ||
| 412 | } | ||
| 413 | }); | ||
| 414 | }); | ||
| 415 | } | ||
| 416 | |||
| 354 | export const router = express.Router(); | 417 | export const router = express.Router(); |
| 355 | 418 | ||
| 356 | router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { | 419 | router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { |
| @@ -809,3 +872,79 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response) | |||
| 809 | return response.status(500).json({ error: 'Search failed' }); | 872 | return response.status(500).json({ error: 'Search failed' }); |
| 810 | } | 873 | } |
| 811 | }); | 874 | }); |
| 875 | |||
| 876 | router.post('/recent', async function (request, response) { | ||
| 877 | try { | ||
| 878 | /** @type {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} */ | ||
| 879 | const allChatFiles = []; | ||
| 880 | |||
| 881 | const getCharacterChatFiles = async () => { | ||
| 882 | const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true }); | ||
| 883 | const pngFiles = pngDirents.filter(e => e.isFile() && path.extname(e.name) === '.png').map(e => e.name); | ||
| 884 | |||
| 885 | for (const pngFile of pngFiles) { | ||
| 886 | const chatsDirectory = pngFile.replace('.png', ''); | ||
| 887 | const pathToChats = path.join(request.user.directories.chats, chatsDirectory); | ||
| 888 | if (!fs.existsSync(pathToChats)) { | ||
| 889 | continue; | ||
| 890 | } | ||
| 891 | const pathStats = await fs.promises.stat(pathToChats); | ||
| 892 | if (pathStats.isDirectory()) { | ||
| 893 | const chatFiles = await fs.promises.readdir(pathToChats); | ||
| 894 | const jsonlFiles = chatFiles.filter(file => path.extname(file) === '.jsonl'); | ||
| 895 | |||
| 896 | for (const file of jsonlFiles) { | ||
| 897 | const filePath = path.join(pathToChats, file); | ||
| 898 | const stats = await fs.promises.stat(filePath); | ||
| 899 | allChatFiles.push({ pngFile, filePath, mtime: stats.mtimeMs }); | ||
| 900 | } | ||
| 901 | } | ||
| 902 | } | ||
| 903 | }; | ||
| 904 | |||
| 905 | const getGroupChatFiles = async () => { | ||
| 906 | const groupDirents = await fs.promises.readdir(request.user.directories.groups, { withFileTypes: true }); | ||
| 907 | const groups = groupDirents.filter(e => e.isFile() && path.extname(e.name) === '.json').map(e => e.name); | ||
| 908 | |||
| 909 | for (const group of groups) { | ||
| 910 | try { | ||
| 911 | const groupPath = path.join(request.user.directories.groups, group); | ||
| 912 | const groupContents = await fs.promises.readFile(groupPath, 'utf8'); | ||
| 913 | const groupData = JSON.parse(groupContents); | ||
| 914 | |||
| 915 | if (Array.isArray(groupData.chats)) { | ||
| 916 | for (const chat of groupData.chats) { | ||
| 917 | const filePath = path.join(request.user.directories.groupChats, `${chat}.jsonl`); | ||
| 918 | if (!fs.existsSync(filePath)) { | ||
| 919 | continue; | ||
| 920 | } | ||
| 921 | const stats = await fs.promises.stat(filePath); | ||
| 922 | allChatFiles.push({ groupId: groupData.id, filePath, mtime: stats.mtimeMs }); | ||
| 923 | } | ||
| 924 | } | ||
| 925 | } catch (error) { | ||
| 926 | // Skip group files that can't be read or parsed | ||
| 927 | continue; | ||
| 928 | } | ||
| 929 | } | ||
| 930 | }; | ||
| 931 | |||
| 932 | await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles()]); | ||
| 933 | |||
| 934 | const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER); | ||
| 935 | const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max); | ||
| 936 | const jsonFilesPromise = recentChats.map((file) => { | ||
| 937 | return file.groupId | ||
| 938 | ? getChatInfo(file.filePath, { group: file.groupId }, true) | ||
| 939 | : getChatInfo(file.filePath, { avatar: file.pngFile }, false); | ||
| 940 | }); | ||
| 941 | |||
| 942 | const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); | ||
| 943 | const validFiles = chatData.filter(i => i.file_name); | ||
| 944 | |||
| 945 | return response.send(validFiles); | ||
| 946 | } catch (error) { | ||
| 947 | console.error(error); | ||
| 948 | return response.sendStatus(500); | ||
| 949 | } | ||
| 950 | }); | ||
| @@ -6,7 +6,6 @@ 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'; | ||
| 10 | 9 | ||
| 11 | export const router = express.Router(); | 10 | export const router = express.Router(); |
| 12 | 11 | ||
| @@ -132,41 +131,3 @@ router.post('/delete', async (request, response) => { | |||
| 132 | 131 | ||
| 133 | return response.send({ ok: true }); | 132 | return response.send({ ok: true }); |
| 134 | }); | 133 | }); |
| 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 | }); | ||