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
Signed| @@ -8341,8 +8341,6 @@ export async function displayPastChats(hightlightNames = []) { | |||
| 8341 | 8341 | ||
| 8342 | async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) { | 8342 | async 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))); |
| 8364 | 8362 | ||
| 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 | } |
| 11011 | 11009 | ||
| 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 () { | |||
| 11023 | 11021 | ||
| 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); |
| 11028 | 11026 | ||
| 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 | } |
| 11034 | 11032 | ||
| 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 | }); |
| 11040 | 11038 | ||
| @@ -11068,8 +11066,7 @@ jQuery(async function () { | |||
| 11068 | $('#character_popup').css('display', 'none'); | 11066 | $('#character_popup').css('display', 'none'); |
| 11069 | }); | 11067 | }); |
| 11070 | 11068 | ||
| 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); |
| 11085 | 11082 | ||
| 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 () { | |||
| 11199 | 11192 | ||
| 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', ''); | ||
| 11204 | 11196 | ||
| 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`); |
| 11226 | 11218 | ||
| 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, |
| @@ -656,7 +656,7 @@ export function initBookmarks() { | |||
| 656 | 656 | ||
| 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'); |
| 660 | 660 | ||
| 661 | if (!fileName) { | 661 | if (!fileName) { |
| 662 | return; | 662 | return; |
| @@ -77,13 +77,12 @@ function getBackupFunction(handle) { | |||
| 77 | } | 77 | } |
| 78 | 78 | ||
| 79 | /** | 79 | /** |
| 80 | * Gets a preview message from an array of chat messages | 80 | * Gets a preview message from a chat message string. |
| 81 | * @param {Array<Object>} messages - Array of chat messages, each with a 'mes' property | 81 | * @param {string} [lastMessage] - The message to truncate |
| 82 | * @returns {string} A truncated preview of the last message or empty string if no messages | 82 | * @returns {string} A truncated preview of the last message or empty string if no messages |
| 83 | */ | 83 | */ |
| 84 | function getPreviewMessage(messages) { | 84 | function getPreviewMessage(lastMessage) { |
| 85 | const strlen = 400; | 85 | const strlen = 400; |
| 86 | const lastMessage = messages[messages.length - 1]?.mes; | ||
| 87 | 86 | ||
| 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 format | 340 | * @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 file | 341 | * @property {number} [chat_items] - The number of chat items in the file |
| 343 | * @property {string} [mes] - The last message in the chat | 342 | * @property {string} [mes] - The last message in the chat |
| 344 | * @property {number} [last_mes] - The timestamp of the last message | 343 | * @property {number|string} [last_mes] - The timestamp of the last message |
| 345 | * @property {object} [chat_metadata] - Additional chat metadata | 344 | * @property {object} [chat_metadata] - Additional chat metadata |
| 345 | * @property {boolean} [match] - Whether the chat matches the search criteria | ||
| 346 | */ | 346 | */ |
| 347 | 347 | ||
| 348 | /** | 348 | /** |
| @@ -350,14 +350,19 @@ async function checkChatIntegrity(filePath, integritySlug) { | |||
| 350 | * @param {string} pathToFile - Path to the chat file | 350 | * @param {string} pathToFile - Path to the chat file |
| 351 | * @param {object} additionalData - Additional data to include in the result | 351 | * @param {object} additionalData - Additional data to include in the result |
| 352 | * @param {boolean} withMetadata - Whether to read chat metadata | 352 | * @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 | */ |
| 355 | export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false) { | 358 | export 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'); | ||
| 359 | 363 | ||
| 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 | |||
| 380 | 385 | ||
| 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; | ||
| 402 | 416 | ||
| 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 | }); |
| 834 | 848 | ||
| 835 | router.post('/search', validateAvatarUrlMiddleware, function (request, response) { | 849 | router.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 = []; |
| 839 | 855 | ||
| 840 | if (group_id) { | 856 | if (group_id) { |
| 841 | // Find group's chat IDs first | 857 | // 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'); |
| 845 | 861 | ||
| 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 | } |
| 858 | 874 | ||
| 859 | if (!targetGroup?.chats) { | 875 | if (!Array.isArray(targetGroup?.chats)) { |
| 860 | return response.send([]); | 876 | return response.send([]); |
| 861 | } | 877 | } |
| 862 | 878 | ||
| 863 | // Find group chat files for given group ID | 879 | // 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.chats | 881 | 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 directory | 885 | // 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 | } |
| 885 | 892 | ||
| 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 | } |
| 898 | 897 | ||
| 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 = []; |
| 900 | 908 | ||
| 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'); | ||
| 905 | 911 | ||
| 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 | }; | ||
| 909 | 920 | ||
| 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; | ||
| 912 | 924 | ||
| 913 | // If no search query, just return metadata | 925 | // 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 | } |
| 924 | 929 | ||
| 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 | } |
| 929 | 934 | ||
| 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 | } |
| 940 | 946 | ||
| 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' }); |