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
8342async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {8342async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
8343 try {8343 try {
8344 const trimExtension = (fileName) => String(fileName).replace('.jsonl', '');
8345
8346 const response = await fetch('/api/chats/search', {8344 const response = await fetch('/api/chats/search', {
8347 method: 'POST',8345 method: 'POST',
8348 headers: getRequestHeaders(),8346 headers: getRequestHeaders(),
@@ -8363,7 +8361,7 @@ async function displayChats(searchQuery, currentChat, displayName, avatarImg, se
8363 filteredData.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));8361 filteredData.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
83648362
8365 for (const chat of filteredData) {8363 for (const chat of filteredData) {
8366 const isSelected = trimExtension(currentChat) === trimExtension(chat.file_name);8364 const isSelected = currentChat === chat.file_name;
8367 const template = $('#past_chat_template .select_chat_block_wrapper').clone();8365 const template = $('#past_chat_template .select_chat_block_wrapper').clone();
8368 template.find('.select_chat_block').attr('file_name', chat.file_name);8366 template.find('.select_chat_block').attr('file_name', chat.file_name);
8369 template.find('.avatar img').attr('src', avatarImg);8367 template.find('.avatar img').attr('src', avatarImg);
@@ -11006,7 +11004,7 @@ jQuery(async function () {
11006 if (group) {11004 if (group) {
11007 await deleteGroupChat(group, chatFile);11005 await deleteGroupChat(group, chatFile);
11008 } else {11006 } else {
11009 await delChat(chatFile);11007 await delChat(`${chatFile}.jsonl`);
11010 }11008 }
1101111009
11012 if (fromSlashCommand) { // When called from `/delchat` command, don't re-open the history view.11010 if (fromSlashCommand) { // When called from `/delchat` command, don't re-open the history view.
@@ -11023,18 +11021,18 @@ jQuery(async function () {
1102311021
11024 $(document).on('click', '.PastChat_cross', async function (e, { fromSlashCommand = false } = {}) {11022 $(document).on('click', '.PastChat_cross', async function (e, { fromSlashCommand = false } = {}) {
11025 e.stopPropagation();11023 e.stopPropagation();
11026 chat_file_for_del = $(this).attr('file_name');11024 const deleteFileName = $(this).attr('file_name');
11027 console.debug('detected cross click for' + chat_file_for_del);11025 console.debug('detected cross click for' + deleteFileName);
1102811026
11029 // Skip confirmation if called from a slash command.11027 // Skip confirmation if called from a slash command.
11030 if (fromSlashCommand) {11028 if (fromSlashCommand) {
11031 await handleDeleteChat(chat_file_for_del, selected_group, true);11029 await handleDeleteChat(deleteFileName, selected_group, true);
11032 return;11030 return;
11033 }11031 }
1103411032
11035 const result = await callGenericPopup('<h3>' + t`Delete the Chat File?` + '</h3>', POPUP_TYPE.CONFIRM);11033 const result = await callGenericPopup('<h3>' + t`Delete the Chat File?` + '</h3>', POPUP_TYPE.CONFIRM);
11036 if (result === POPUP_RESULT.AFFIRMATIVE) {11034 if (result === POPUP_RESULT.AFFIRMATIVE) {
11037 await handleDeleteChat(chat_file_for_del, selected_group, false);11035 await handleDeleteChat(deleteFileName, selected_group, false);
11038 }11036 }
11039 });11037 });
1104011038
@@ -11068,8 +11066,7 @@ jQuery(async function () {
11068 $('#character_popup').css('display', 'none');11066 $('#character_popup').css('display', 'none');
11069 });11067 });
1107011068
11071 $('#dialogue_popup_ok').on('click', async function (_e, customData) {11069 $('#dialogue_popup_ok').on('click', async function (_e) {
11072 const fromSlashCommand = customData?.fromSlashCommand || false;
11073 dialogueCloseStop = false;11070 dialogueCloseStop = false;
11074 $('#shadow_popup').transition({11071 $('#shadow_popup').transition({
11075 opacity: 0,11072 opacity: 0,
@@ -11083,10 +11080,6 @@ jQuery(async function () {
11083 $('#dialogue_popup').removeClass('wide_dialogue_popup');11080 $('#dialogue_popup').removeClass('wide_dialogue_popup');
11084 }, animation_duration);11081 }, animation_duration);
1108511082
11086 if (popup_type == 'del_chat') {
11087 await handleDeleteChat(chat_file_for_del, selected_group, fromSlashCommand);
11088 }
11089
11090 if (dialogueResolve) {11083 if (dialogueResolve) {
11091 if (popup_type == 'input') {11084 if (popup_type == 'input') {
11092 dialogueResolve($('#dialogue_popup_input').val());11085 dialogueResolve($('#dialogue_popup_input').val());
@@ -11199,8 +11192,7 @@ jQuery(async function () {
1119911192
11200 $(document).on('click', '.renameChatButton', async function (e) {11193 $(document).on('click', '.renameChatButton', async function (e) {
11201 e.stopPropagation();11194 e.stopPropagation();
11202 const oldFileNameFull = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();11195 const oldFileName = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();
11203 const oldFileName = oldFileNameFull.replace('.jsonl', '');
1120411196
11205 const popupText = await renderTemplateAsync('chatRename');11197 const popupText = await renderTemplateAsync('chatRename');
11206 const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, oldFileName);11198 const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, oldFileName);
@@ -11221,10 +11213,9 @@ jQuery(async function () {
11221 e.stopPropagation();11213 e.stopPropagation();
11222 const format = $(this).data('format') || 'txt';11214 const format = $(this).data('format') || 'txt';
11223 await saveChatConditional();11215 await saveChatConditional();
11224 const filenamefull = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();11216 const filename = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();
11225 console.log(`exporting ${filenamefull} in ${format} format`);11217 console.log(`exporting ${filename} in ${format} format`);
1122611218
11227 const filename = filenamefull.replace('.jsonl', '');
11228 const body = {11219 const body = {
11229 is_group: !!selected_group,11220 is_group: !!selected_group,
11230 avatar_url: characters[this_chid]?.avatar,11221 avatar_url: characters[this_chid]?.avatar,
public/scripts/bookmarks.js+1 -1
@@ -656,7 +656,7 @@ export function initBookmarks() {
656656
657 const fileName = $(this).hasClass('mes_bookmark')657 const fileName = $(this).hasClass('mes_bookmark')
658 ? $(this).closest('.mes').attr('bookmark_link')658 ? $(this).closest('.mes').attr('bookmark_link')
659 : $(this).attr('file_name').replace('.jsonl', '');659 : $(this).attr('file_name');
660660
661 if (!fileName) {661 if (!fileName) {
662 return;662 return;
src/endpoints/chats.js+63 -60
@@ -77,13 +77,12 @@ function getBackupFunction(handle) {
77}77}
7878
79/**79/**
80 * Gets a preview message from an array of chat messages80 * Gets a preview message from a chat message string.
81 * @param {Array<Object>} messages - Array of chat messages, each with a 'mes' property81 * @param {string} [lastMessage] - The message to truncate
82 * @returns {string} A truncated preview of the last message or empty string if no messages82 * @returns {string} A truncated preview of the last message or empty string if no messages
83 */83 */
84function getPreviewMessage(messages) {84function getPreviewMessage(lastMessage) {
85 const strlen = 400;85 const strlen = 400;
86 const lastMessage = messages[messages.length - 1]?.mes;
8786
88 if (!lastMessage) {87 if (!lastMessage) {
89 return '';88 return '';
@@ -341,8 +340,9 @@ async function checkChatIntegrity(filePath, integritySlug) {
341 * @property {string} [file_size] - The size of the chat file in a human-readable format340 * @property {string} [file_size] - The size of the chat file in a human-readable format
342 * @property {number} [chat_items] - The number of chat items in the file341 * @property {number} [chat_items] - The number of chat items in the file
343 * @property {string} [mes] - The last message in the chat342 * @property {string} [mes] - The last message in the chat
344 * @property {number} [last_mes] - The timestamp of the last message343 * @property {number|string} [last_mes] - The timestamp of the last message
345 * @property {object} [chat_metadata] - Additional chat metadata344 * @property {object} [chat_metadata] - Additional chat metadata
345 * @property {boolean} [match] - Whether the chat matches the search criteria
346 */346 */
347347
348/**348/**
@@ -350,14 +350,19 @@ async function checkChatIntegrity(filePath, integritySlug) {
350 * @param {string} pathToFile - Path to the chat file350 * @param {string} pathToFile - Path to the chat file
351 * @param {object} additionalData - Additional data to include in the result351 * @param {object} additionalData - Additional data to include in the result
352 * @param {boolean} withMetadata - Whether to read chat metadata352 * @param {boolean} withMetadata - Whether to read chat metadata
353 * @param {ChatMatchFunction|null} matcher - Optional function to match messages
353 * @returns {Promise<ChatInfo>}354 * @returns {Promise<ChatInfo>}
355 *
356 * @typedef {(text: string) => boolean} ChatMatchFunction
354 */357 */
355export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false) {358export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false, matcher = null) {
356 return new Promise(async (res) => {359 return new Promise(async (res) => {
357 const parsedPath = path.parse(pathToFile);360 const parsedPath = path.parse(pathToFile);
358 const stats = await fs.promises.stat(pathToFile);361 const stats = await fs.promises.stat(pathToFile);
362 const hasMatcher = (typeof matcher === 'function');
359363
360 const chatData = {364 const chatData = {
365 match: false,
361 file_id: parsedPath.name,366 file_id: parsedPath.name,
362 file_name: parsedPath.base,367 file_name: parsedPath.base,
363 file_size: formatBytes(stats.size),368 file_size: formatBytes(stats.size),
@@ -380,6 +385,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
380385
381 let lastLine;386 let lastLine;
382 let itemCounter = 0;387 let itemCounter = 0;
388 let hasAnyMatch = false;
383 rl.on('line', (line) => {389 rl.on('line', (line) => {
384 if (withMetadata && itemCounter === 0) {390 if (withMetadata && itemCounter === 0) {
385 const jsonData = tryParse(line);391 const jsonData = tryParse(line);
@@ -387,6 +393,13 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
387 chatData.chat_metadata = jsonData.chat_metadata;393 chatData.chat_metadata = jsonData.chat_metadata;
388 }394 }
389 }395 }
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 }
390 itemCounter++;403 itemCounter++;
391 lastLine = line;404 lastLine = line;
392 });405 });
@@ -399,6 +412,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
399 chatData.chat_items = (itemCounter - 1);412 chatData.chat_items = (itemCounter - 1);
400 chatData.mes = jsonData.mes || '[The message is empty]';413 chatData.mes = jsonData.mes || '[The message is empty]';
401 chatData.last_mes = jsonData.send_date || new Date(Math.round(stats.mtimeMs)).toISOString();414 chatData.last_mes = jsonData.send_date || new Date(Math.round(stats.mtimeMs)).toISOString();
415 chatData.match = hasMatcher ? hasAnyMatch : true;
402416
403 res(chatData);417 res(chatData);
404 } else {418 } else {
@@ -832,16 +846,18 @@ router.post('/group/save', async function (request, response) {
832 }846 }
833});847});
834848
835router.post('/search', validateAvatarUrlMiddleware, function (request, response) {849router.post('/search', validateAvatarUrlMiddleware, async function (request, response) {
836 try {850 try {
837 const { query, avatar_url, group_id } = request.body;851 const { query, avatar_url, group_id } = request.body;
852
853 /** @type {string[]} */
838 let chatFiles = [];854 let chatFiles = [];
839855
840 if (group_id) {856 if (group_id) {
841 // Find group's chat IDs first857 // Find group's chat IDs first
842 const groupDir = path.join(request.user.directories.groups);858 const groupDir = path.join(request.user.directories.groups);
843 const groupFiles = fs.readdirSync(groupDir)859 const groupFiles = fs.readdirSync(groupDir)
844 .filter(file => file.endsWith('.json'));860 .filter(file => path.extname(file) === '.json');
845861
846 let targetGroup;862 let targetGroup;
847 for (const groupFile of groupFiles) {863 for (const groupFile of groupFiles) {
@@ -856,24 +872,15 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
856 }872 }
857 }873 }
858874
859 if (!targetGroup?.chats) {875 if (!Array.isArray(targetGroup?.chats)) {
860 return response.send([]);876 return response.send([]);
861 }877 }
862878
863 // Find group chat files for given group ID879 // Find group chat files for given group ID
864 const groupChatsDir = path.join(request.user.directories.groupChats);880 const groupChatsDir = path.join(request.user.directories.groupChats);
865 chatFiles = targetGroup.chats881 chatFiles = targetGroup.chats
866 .map(chatId => {882 .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);
877 } else {884 } else {
878 // Regular character chat directory885 // Regular character chat directory
879 const character_name = avatar_url.replace('.png', '');886 const character_name = avatar_url.replace('.png', '');
@@ -884,64 +891,60 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
884 }891 }
885892
886 chatFiles = fs.readdirSync(directoryPath)893 chatFiles = fs.readdirSync(directoryPath)
887 .filter(file => file.endsWith('.jsonl'))894 .filter(file => path.extname(file) === '.jsonl')
888 .map(fileName => {895 .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 });
897 }896 }
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 */
899 const results = [];907 const results = [];
900908
901 // Search logic909 /** @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;
908 }916 }
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
913 // If no search query, just return metadata925 // Skip corrupted or invalid chat files
914 if (!query) {926 if (!chatInfo.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 });
922 continue;927 continue;
923 }928 }
924929
925 // Search through title and messages of the chat930 // 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) {
931 results.push({937 results.push({
932 file_name: chatFile.file_name,938 file_name: chatInfo.file_id,
933 file_size: chatFile.file_size,939 file_size: chatInfo.file_size,
934 message_count: messages.length,940 message_count: chatInfo.chat_items,
935 last_mes: lastMesDate,941 last_mes: chatInfo.last_mes,
936 preview_message: getPreviewMessage(messages),942 preview_message: getPreviewMessage(chatInfo.mes),
937 });943 });
938 }944 }
939 }945 }
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());
943 return response.send(results);947 return response.send(results);
944
945 } catch (error) {948 } catch (error) {
946 console.error('Chat search error:', error);949 console.error('Chat search error:', error);
947 return response.status(500).json({ error: 'Search failed' });950 return response.status(500).json({ error: 'Search failed' });