Refactor /search to use per-line async parsing (#5085) * Refactor /search to use per-line async parsing Supersedes #5047 * Use named type * Cache query fragments, add file name match * Skip invalid chat files in search * Remove extensions from /search results * Use file_id when appropriate * Revert "Use file_id when appropriate" This reverts commit aaa0274b53da6a677183b6f923163053cfd6d569. * Clean-up file extension handling * Move extension append down for non-group

3f8acaad4e2ac6251e9b714726eed180c2de7fb7

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

Signed
3 files changed, +74 -80Showing whitespace changes
public/script.js+10 -19
@@ -8341,8 +8341,6 @@ export async function displayPastChats(hightlightNames = []) {
83418341
83428342async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
83438343 try {
8344- const trimExtension = (fileName) => String(fileName).replace('.jsonl', '');
8345-
83468344 const response = await fetch('/api/chats/search', {
83478345 method: 'POST',
83488346 headers: getRequestHeaders(),
@@ -8363,7 +8361,7 @@ async function displayChats(searchQuery, currentChat, displayName, avatarImg, se
83638361 filteredData.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
83648362
83658363 for (const chat of filteredData) {
83668364 const isSelected = trimExtension(currentChat) === trimExtension(chat.file_name);
83678365 const template = $('#past_chat_template .select_chat_block_wrapper').clone();
83688366 template.find('.select_chat_block').attr('file_name', chat.file_name);
83698367 template.find('.avatar img').attr('src', avatarImg);
@@ -11006,7 +11004,7 @@ jQuery(async function () {
1100611004 if (group) {
1100711005 await deleteGroupChat(group, chatFile);
1100811006 } else {
1100911007 await delChat(`${chatFile}.jsonl`);
1101011008 }
1101111009
1101211010 if (fromSlashCommand) { // When called from `/delchat` command, don't re-open the history view.
@@ -11023,18 +11021,18 @@ jQuery(async function () {
1102311021
1102411022 $(document).on('click', '.PastChat_cross', async function (e, { fromSlashCommand = false } = {}) {
1102511023 e.stopPropagation();
1102611024 chat_file_for_delconst deleteFileName = $(this).attr('file_name');
1102711025 console.debug('detected cross click for' + chat_file_for_deldeleteFileName);
1102811026
1102911027 // Skip confirmation if called from a slash command.
1103011028 if (fromSlashCommand) {
1103111029 await handleDeleteChat(chat_file_for_deldeleteFileName, selected_group, true);
1103211030 return;
1103311031 }
1103411032
1103511033 const result = await callGenericPopup('<h3>' + t`Delete the Chat File?` + '</h3>', POPUP_TYPE.CONFIRM);
1103611034 if (result === POPUP_RESULT.AFFIRMATIVE) {
1103711035 await handleDeleteChat(chat_file_for_deldeleteFileName, selected_group, false);
1103811036 }
1103911037 });
1104011038
@@ -11068,8 +11066,7 @@ jQuery(async function () {
1106811066 $('#character_popup').css('display', 'none');
1106911067 });
1107011068
1107111069 $('#dialogue_popup_ok').on('click', async function (_e, customData) {
11072- const fromSlashCommand = customData?.fromSlashCommand || false;
1107311070 dialogueCloseStop = false;
1107411071 $('#shadow_popup').transition({
1107511072 opacity: 0,
@@ -11083,10 +11080,6 @@ jQuery(async function () {
1108311080 $('#dialogue_popup').removeClass('wide_dialogue_popup');
1108411081 }, animation_duration);
1108511082
11086- if (popup_type == 'del_chat') {
11087- await handleDeleteChat(chat_file_for_del, selected_group, fromSlashCommand);
11088- }
11089-
1109011083 if (dialogueResolve) {
1109111084 if (popup_type == 'input') {
1109211085 dialogueResolve($('#dialogue_popup_input').val());
@@ -11199,8 +11192,7 @@ jQuery(async function () {
1119911192
1120011193 $(document).on('click', '.renameChatButton', async function (e) {
1120111194 e.stopPropagation();
1120211195 const oldFileNameFulloldFileName = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();
11203- const oldFileName = oldFileNameFull.replace('.jsonl', '');
1120411196
1120511197 const popupText = await renderTemplateAsync('chatRename');
1120611198 const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, oldFileName);
@@ -11221,10 +11213,9 @@ jQuery(async function () {
1122111213 e.stopPropagation();
1122211214 const format = $(this).data('format') || 'txt';
1122311215 await saveChatConditional();
1122411216 const filenamefullfilename = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();
1122511217 console.log(`exporting ${filenamefullfilename} in ${format} format`);
1122611218
11227- const filename = filenamefull.replace('.jsonl', '');
1122811219 const body = {
1122911220 is_group: !!selected_group,
1123011221 avatar_url: characters[this_chid]?.avatar,
public/scripts/bookmarks.js+1 -1
@@ -656,7 +656,7 @@ export function initBookmarks() {
656656
657657 const fileName = $(this).hasClass('mes_bookmark')
658658 ? $(this).closest('.mes').attr('bookmark_link')
659659 : $(this).attr('file_name').replace('.jsonl', '');
660660
661661 if (!fileName) {
662662 return;
src/endpoints/chats.js+63 -60
@@ -77,13 +77,12 @@ function getBackupFunction(handle) {
7777}
7878
7979/**
8080 * Gets a preview message from an array ofa chat messagesmessage string.
8181 * @param {Array<Object>string} messages[lastMessage] - Array of chat messages, each withThe amessage 'mes'to propertytruncate
8282 * @returns {string} A truncated preview of the last message or empty string if no messages
8383 */
8484function getPreviewMessage(messageslastMessage) {
8585 const strlen = 400;
86- const lastMessage = messages[messages.length - 1]?.mes;
8786
8887 if (!lastMessage) {
8988 return '';
@@ -341,8 +340,9 @@ async function checkChatIntegrity(filePath, integritySlug) {
341340 * @property {string} [file_size] - The size of the chat file in a human-readable format
342341 * @property {number} [chat_items] - The number of chat items in the file
343342 * @property {string} [mes] - The last message in the chat
344343 * @property {number|string} [last_mes] - The timestamp of the last message
345344 * @property {object} [chat_metadata] - Additional chat metadata
345+ * @property {boolean} [match] - Whether the chat matches the search criteria
346346 */
347347
348348/**
@@ -350,14 +350,19 @@ async function checkChatIntegrity(filePath, integritySlug) {
350350 * @param {string} pathToFile - Path to the chat file
351351 * @param {object} additionalData - Additional data to include in the result
352352 * @param {boolean} withMetadata - Whether to read chat metadata
353+ * @param {ChatMatchFunction|null} matcher - Optional function to match messages
353354 * @returns {Promise<ChatInfo>}
355+ *
356+ * @typedef {(text: string) => boolean} ChatMatchFunction
354357 */
355358export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false, matcher = null) {
356359 return new Promise(async (res) => {
357360 const parsedPath = path.parse(pathToFile);
358361 const stats = await fs.promises.stat(pathToFile);
362+ const hasMatcher = (typeof matcher === 'function');
359363
360364 const chatData = {
365+ match: false,
361366 file_id: parsedPath.name,
362367 file_name: parsedPath.base,
363368 file_size: formatBytes(stats.size),
@@ -380,6 +385,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
380385
381386 let lastLine;
382387 let itemCounter = 0;
388+ let hasAnyMatch = false;
383389 rl.on('line', (line) => {
384390 if (withMetadata && itemCounter === 0) {
385391 const jsonData = tryParse(line);
@@ -387,6 +393,13 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
387393 chatData.chat_metadata = jsonData.chat_metadata;
388394 }
389395 }
396+ // Skip matching if any match was already found
397+ if (hasMatcher && !hasAnyMatch && itemCounter > 0) {
398+ const jsonData = tryParse(line);
399+ if (jsonData && matcher(jsonData.mes || '')) {
400+ hasAnyMatch = true;
401+ }
402+ }
390403 itemCounter++;
391404 lastLine = line;
392405 });
@@ -399,6 +412,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
399412 chatData.chat_items = (itemCounter - 1);
400413 chatData.mes = jsonData.mes || '[The message is empty]';
401414 chatData.last_mes = jsonData.send_date || new Date(Math.round(stats.mtimeMs)).toISOString();
415+ chatData.match = hasMatcher ? hasAnyMatch : true;
402416
403417 res(chatData);
404418 } else {
@@ -832,16 +846,18 @@ router.post('/group/save', async function (request, response) {
832846 }
833847});
834848
835849router.post('/search', validateAvatarUrlMiddleware, async function (request, response) {
836850 try {
837851 const { query, avatar_url, group_id } = request.body;
852+
853+ /** @type {string[]} */
838854 let chatFiles = [];
839855
840856 if (group_id) {
841857 // Find group's chat IDs first
842858 const groupDir = path.join(request.user.directories.groups);
843859 const groupFiles = fs.readdirSync(groupDir)
844860 .filter(file => filepath.endsWithextname(file) === '.json'));
845861
846862 let targetGroup;
847863 for (const groupFile of groupFiles) {
@@ -856,24 +872,15 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
856872 }
857873 }
858874
859875 if (!Array.isArray(targetGroup?.chats)) {
860876 return response.send([]);
861877 }
862878
863879 // Find group chat files for given group ID
864880 const groupChatsDir = path.join(request.user.directories.groupChats);
865881 chatFiles = targetGroup.chats
866882 .map(chatId => path.join(groupChatsDir, `${chatId}.jsonl`))
867- const filePath = path.join(groupChatsDir, `${chatId}.jsonl`);
883+ .filter(fileName => fs.existsSync(fileName));
868- if (!fs.existsSync(filePath)) return null;
869- const stats = fs.statSync(filePath);
870- return {
871- file_name: chatId,
872- file_size: formatBytes(stats.size),
873- path: filePath,
874- };
875- })
876- .filter(x => x);
877884 } else {
878885 // Regular character chat directory
879886 const character_name = avatar_url.replace('.png', '');
@@ -884,64 +891,60 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
884891 }
885892
886893 chatFiles = fs.readdirSync(directoryPath)
887894 .filter(file => filepath.endsWithextname(file) === '.jsonl'))
888895 .map(fileName => {path.join(directoryPath, fileName));
889- const filePath = path.join(directoryPath, fileName);
890- const stats = fs.statSync(filePath);
891- return {
892- file_name: fileName,
893- file_size: formatBytes(stats.size),
894- path: filePath,
895- };
896- });
897896 }
898897
898+ /**
899+ * @type {SearchChatResult[]}
900+ * @typedef {object} SearchChatResult
901+ * @property {string} [file_name] - The name of the chat file
902+ * @property {string} [file_size] - The size of the chat file in a human-readable format
903+ * @property {number} [message_count] - The number of messages in the chat
904+ * @property {number|string} [last_mes] - The timestamp of the last message
905+ * @property {string} [preview_message] - A preview of the last message
906+ */
899907 const results = [];
900908
901- // Search logic
909+ /** @type {string[]} */
902- for (const chatFile of chatFiles) {
910+ const fragments = query ? query.trim().toLowerCase().split(/\s+/).filter(x => x) : [];
903- const data = getChatData(chatFile.path);
904- const messages = data.filter(x => x && typeof x.mes === 'string');
905911
906- if (query && messages.length === 0) {
912+ /** @type {ChatMatchFunction} */
907- continue;
913+ const hasTextMatch = (text) => {
914+ if (fragments.length === 0) {
915+ return true;
908916 }
917+ const loweredText = String(text).toLowerCase();
918+ return fragments.every(fragment => loweredText.includes(fragment));
919+ };
909920
910- const lastMessage = messages[messages.length - 1];
921+ for (const chatFile of chatFiles) {
911- const lastMesDate = lastMessage?.send_date || new Date(fs.statSync(chatFile.path).mtimeMs).toISOString();
922+ const chatInfo = await getChatInfo(chatFile, {}, false, hasTextMatch);
923+ const hasMatch = hasTextMatch(chatInfo.file_id ?? '') || chatInfo.match;
912924
913925 // If noSkip searchcorrupted query,or justinvalid returnchat metadatafiles
914926 if (!querychatInfo.file_name) {
915- results.push({
916- file_name: chatFile.file_name,
917- file_size: chatFile.file_size,
918- message_count: messages.length,
919- last_mes: lastMesDate,
920- preview_message: getPreviewMessage(messages),
921- });
922927 continue;
923928 }
924929
925- // Search through title and messages of the chat
930+ // Empty chats without a file name match are skipped when searching with a query
926- const fragments = query.trim().toLowerCase().split(/\s+/).filter(x => x);
931+ if (query && chatInfo.chat_items === 0 && !hasMatch) {
927- const text = [path.parse(chatFile.path).name, ...messages.map(message => message?.mes)].join('\n').toLowerCase();
932+ continue;
928- const hasMatch = fragments.every(fragment => text.includes(fragment));
933+ }
929934
930- if (hasMatch) {
935+ // If no search query or a match was found, include the chat in results
936+ if (!query || hasMatch) {
931937 results.push({
932938 file_name: chatFilechatInfo.file_namefile_id,
933939 file_size: chatFilechatInfo.file_size,
934940 message_count: messageschatInfo.lengthchat_items,
935941 last_mes: lastMesDatechatInfo.last_mes,
936942 preview_message: getPreviewMessage(messageschatInfo.mes),
937943 });
938944 }
939945 }
940946
941- // Sort by last message date descending
942- results.sort((a, b) => new Date(b.last_mes).getTime() - new Date(a.last_mes).getTime());
943947 return response.send(results);
944-
945948 } catch (error) {
946949 console.error('Chat search error:', error);
947950 return response.status(500).json({ error: 'Search failed' });