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';
31const defaultAssistantAvatar = 'default_Assistant.png';31const defaultAssistantAvatar = 'default_Assistant.png';
3232
33const DEFAULT_DISPLAYED = 3;33const DEFAULT_DISPLAYED = 3;
34const MAX_DISPLAYED = 20;34const MAX_DISPLAYED = 15;
3535
36export function getPermanentAssistantAvatar() {36export 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 default247 * @property {boolean} hidden Chat will be hidden by default
248 */248 */
249async function getRecentChats() {249async 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 };
261255
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 };
273260
274 /** @type {RecentChat[]} */261 /** @type {RecentChat[]} */
275 const data = await Promise.all([charData(), groupData()]).then(res => res.flat());262 const data = await response.json();
276263
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 });
292279
293 return data.slice(0, MAX_DISPLAYED);280 return data;
294}281}
295282
296export async function openPermanentAssistantChat({ tryCreate = true, created = false } = {}) {283export async function openPermanentAssistantChat({ tryCreate = true, created = false } = {}) {
src/endpoints/characters.js+1 -105
@@ -1,7 +1,6 @@
1import path from 'node:path';1import path from 'node:path';
2import fs from 'node:fs';2import fs from 'node:fs';
3import { promises as fsPromises } from 'node:fs';3import { promises as fsPromises } from 'node:fs';
4import readline from 'node:readline';
5import { Buffer } from 'node:buffer';4import { Buffer } from 'node:buffer';
65
7import express from 'express';6import express from 'express';
@@ -22,6 +21,7 @@ import { readWorldInfoFile } from './worldinfo.js';
22import { invalidateThumbnail } from './thumbnails.js';21import { invalidateThumbnail } from './thumbnails.js';
23import { importRisuSprites } from './sprites.js';22import { importRisuSprites } from './sprites.js';
24import { getUserDirectories } from '../users.js';23import { getUserDirectories } from '../users.js';
24import { getChatInfo } from './chats.js';
25const defaultAvatarPath = './public/img/ai4.png';25const defaultAvatarPath = './public/img/ai4.png';
2626
27// With 100 MB limit it would take roughly 3000 characters to reach this limit27// 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}
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 */
952export 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
1000export const router = express.Router();937export const router = express.Router();
1001938
1002router.post('/create', async function (request, response) {939router.post('/create', async function (request, response) {
@@ -1286,47 +1223,6 @@ router.post('/get', validateAvatarUrlMiddleware, async function (request, respon
1286 }1223 }
1287});1224});
12881225
1289router.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
1330router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) {1226router.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);
src/endpoints/chats.js+139 -0
@@ -351,6 +351,69 @@ async function checkChatIntegrity(filePath, integritySlug) {
351 return chatIntegrity === integritySlug;351 return chatIntegrity === integritySlug;
352}352}
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 */
369export 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
354export const router = express.Router();417export const router = express.Router();
355418
356router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {419router.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
876router.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';
6import { sync as writeFileAtomicSync } from 'write-file-atomic';6import { sync as writeFileAtomicSync } from 'write-file-atomic';
77
8import { humanizedISO8601DateTime } from '../util.js';8import { humanizedISO8601DateTime } from '../util.js';
9import { getChatInfo } from './characters.js';
109
11export const router = express.Router();10export const router = express.Router();
1211
@@ -132,41 +131,3 @@ router.post('/delete', async (request, response) => {
132131
133 return response.send({ ok: true });132 return response.send({ ok: true });
134});133});
135
136router.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});