Join recent chat/group queries

587cecb12cdaadf4e09169025e8f0df83c40ee29

Cohee <18619528+Cohee1207@users.noreply.github.com>

4 files changed, +146 -163Showing whitespace changes
public/scripts/welcome-screen.js+6 -19
@@ -31,7 +31,7 @@ const assistantAvatarKey = 'assistant';
3131const defaultAssistantAvatar = 'default_Assistant.png';
3232
3333const DEFAULT_DISPLAYED = 3;
3434const MAX_DISPLAYED = 2015;
3535
3636export function getPermanentAssistantAvatar() {
3737 const assistantAvatar = accountStorage.getItem(assistantAvatarKey);
@@ -247,32 +247,19 @@ async function openRecentGroupChat(groupId, fileName) {
247247 * @property {boolean} hidden Chat will be hidden by default
248248 */
249249async function getRecentChats() {
250250 const charDataresponse = asyncawait fetch() =>'/api/chats/recent', {
251- const response = await fetch('/api/characters/recent', {
252251 method: 'POST',
253252 headers: getRequestHeaders(),
253+ body: JSON.stringify({ max: MAX_DISPLAYED }),
254254 });
255- if (!response.ok) {
256- console.warn('Failed to fetch recent character chats');
257- return [];
258- }
259- return await response.json();
260- };
261255
262- const groupData = async () => {
263- const response = await fetch('/api/groups/recent', {
264- method: 'POST',
265- headers: getRequestHeaders(),
266- });
267256 if (!response.ok) {
268257 console.warn('Failed to fetch recent groupcharacter chats');
269258 return [];
270259 }
271- return await response.json();
272- };
273260
274261 /** @type {RecentChat[]} */
275- const data = await Promise.all([charData(), groupData()]).then(res => res.flat());
262+ const data = await response.json();
276263
277264 data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)))
278265 .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() {
290277 chat.group = chat.group || '';
291278 });
292279
293280 return data.slice(0, MAX_DISPLAYED);
294281}
295282
296283export async function openPermanentAssistantChat({ tryCreate = true, created = false } = {}) {
src/endpoints/characters.js+1 -105
@@ -1,7 +1,6 @@
11import path from 'node:path';
22import fs from 'node:fs';
33import { promises as fsPromises } from 'node:fs';
4-import readline from 'node:readline';
54import { Buffer } from 'node:buffer';
65
76import express from 'express';
@@ -22,6 +21,7 @@ import { readWorldInfoFile } from './worldinfo.js';
2221import { invalidateThumbnail } from './thumbnails.js';
2322import { importRisuSprites } from './sprites.js';
2423import { getUserDirectories } from '../users.js';
24+import { getChatInfo } from './chats.js';
2525const defaultAvatarPath = './public/img/ai4.png';
2626
2727// With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -934,69 +934,6 @@ async function importFromPng(uploadPath, { request }, preservedFileName) {
934934 return '';
935935}
936936
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-
1000937export const router = express.Router();
1001938
1002939router.post('/create', async function (request, response) {
@@ -1286,47 +1223,6 @@ router.post('/get', validateAvatarUrlMiddleware, async function (request, respon
12861223 }
12871224});
12881225
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-
13301226router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) {
13311227 try {
13321228 if (!request.body) return response.sendStatus(400);
src/endpoints/chats.js+139 -0
@@ -351,6 +351,69 @@ async function checkChatIntegrity(filePath, integritySlug) {
351351 return chatIntegrity === integritySlug;
352352}
353353
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+
354417export const router = express.Router();
355418
356419router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
@@ -809,3 +872,79 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
809872 return response.status(500).json({ error: 'Search failed' });
810873 }
811874});
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+});
src/endpoints/groups.js+0 -39
@@ -6,7 +6,6 @@ import sanitize from 'sanitize-filename';
66import { sync as writeFileAtomicSync } from 'write-file-atomic';
77
88import { humanizedISO8601DateTime } from '../util.js';
9-import { getChatInfo } from './characters.js';
109
1110export const router = express.Router();
1211
@@ -132,41 +131,3 @@ router.post('/delete', async (request, response) => {
132131
133132 return response.send({ ok: true });
134133});
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-});