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 | 8342 | async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) { |
| 8343 | 8343 | try { |
| 8344 | - const trimExtension = (fileName) => String(fileName).replace('.jsonl', ''); | |
| 8345 | - | |
| 8346 | 8344 | const response = await fetch('/api/chats/search', { |
| 8347 | 8345 | method: 'POST', |
| 8348 | 8346 | headers: getRequestHeaders(), |
| @@ -8363,7 +8361,7 @@ async function displayChats(searchQuery, currentChat, displayName, avatarImg, se | ||
| 8363 | 8361 | filteredData.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))); |
| 8364 | 8362 | |
| 8365 | 8363 | for (const chat of filteredData) { |
| 8366 | 8364 | const isSelected = trimExtension(currentChat) === trimExtension(chat.file_name); |
| 8367 | 8365 | const template = $('#past_chat_template .select_chat_block_wrapper').clone(); |
| 8368 | 8366 | template.find('.select_chat_block').attr('file_name', chat.file_name); |
| 8369 | 8367 | template.find('.avatar img').attr('src', avatarImg); |
| @@ -11006,7 +11004,7 @@ jQuery(async function () { | ||
| 11006 | 11004 | if (group) { |
| 11007 | 11005 | await deleteGroupChat(group, chatFile); |
| 11008 | 11006 | } else { |
| 11009 | 11007 | await delChat(`${chatFile}.jsonl`); |
| 11010 | 11008 | } |
| 11011 | 11009 | |
| 11012 | 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 | 11022 | $(document).on('click', '.PastChat_cross', async function (e, { fromSlashCommand = false } = {}) { |
| 11025 | 11023 | e.stopPropagation(); |
| 11026 | 11024 | chat_file_for_delconst deleteFileName = $(this).attr('file_name'); |
| 11027 | 11025 | console.debug('detected cross click for' + chat_file_for_deldeleteFileName); |
| 11028 | 11026 | |
| 11029 | 11027 | // Skip confirmation if called from a slash command. |
| 11030 | 11028 | if (fromSlashCommand) { |
| 11031 | 11029 | await handleDeleteChat(chat_file_for_deldeleteFileName, selected_group, true); |
| 11032 | 11030 | return; |
| 11033 | 11031 | } |
| 11034 | 11032 | |
| 11035 | 11033 | const result = await callGenericPopup('<h3>' + t`Delete the Chat File?` + '</h3>', POPUP_TYPE.CONFIRM); |
| 11036 | 11034 | if (result === POPUP_RESULT.AFFIRMATIVE) { |
| 11037 | 11035 | await handleDeleteChat(chat_file_for_deldeleteFileName, selected_group, false); |
| 11038 | 11036 | } |
| 11039 | 11037 | }); |
| 11040 | 11038 | |
| @@ -11068,8 +11066,7 @@ jQuery(async function () { | ||
| 11068 | 11066 | $('#character_popup').css('display', 'none'); |
| 11069 | 11067 | }); |
| 11070 | 11068 | |
| 11071 | 11069 | $('#dialogue_popup_ok').on('click', async function (_e, customData) { |
| 11072 | - const fromSlashCommand = customData?.fromSlashCommand || false; | |
| 11073 | 11070 | dialogueCloseStop = false; |
| 11074 | 11071 | $('#shadow_popup').transition({ |
| 11075 | 11072 | opacity: 0, |
| @@ -11083,10 +11080,6 @@ jQuery(async function () { | ||
| 11083 | 11080 | $('#dialogue_popup').removeClass('wide_dialogue_popup'); |
| 11084 | 11081 | }, animation_duration); |
| 11085 | 11082 | |
| 11086 | - if (popup_type == 'del_chat') { | |
| 11087 | - await handleDeleteChat(chat_file_for_del, selected_group, fromSlashCommand); | |
| 11088 | - } | |
| 11089 | - | |
| 11090 | 11083 | if (dialogueResolve) { |
| 11091 | 11084 | if (popup_type == 'input') { |
| 11092 | 11085 | dialogueResolve($('#dialogue_popup_input').val()); |
| @@ -11199,8 +11192,7 @@ jQuery(async function () { | ||
| 11199 | 11192 | |
| 11200 | 11193 | $(document).on('click', '.renameChatButton', async function (e) { |
| 11201 | 11194 | e.stopPropagation(); |
| 11202 | 11195 | const oldFileNameFulloldFileName = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text(); |
| 11203 | - const oldFileName = oldFileNameFull.replace('.jsonl', ''); | |
| 11204 | 11196 | |
| 11205 | 11197 | const popupText = await renderTemplateAsync('chatRename'); |
| 11206 | 11198 | const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, oldFileName); |
| @@ -11221,10 +11213,9 @@ jQuery(async function () { | ||
| 11221 | 11213 | e.stopPropagation(); |
| 11222 | 11214 | const format = $(this).data('format') || 'txt'; |
| 11223 | 11215 | await saveChatConditional(); |
| 11224 | 11216 | const filenamefullfilename = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text(); |
| 11225 | 11217 | console.log(`exporting ${filenamefullfilename} in ${format} format`); |
| 11226 | 11218 | |
| 11227 | - const filename = filenamefull.replace('.jsonl', ''); | |
| 11228 | 11219 | const body = { |
| 11229 | 11220 | is_group: !!selected_group, |
| 11230 | 11221 | avatar_url: characters[this_chid]?.avatar, |
| @@ -656,7 +656,7 @@ export function initBookmarks() { | ||
| 656 | 656 | |
| 657 | 657 | const fileName = $(this).hasClass('mes_bookmark') |
| 658 | 658 | ? $(this).closest('.mes').attr('bookmark_link') |
| 659 | 659 | : $(this).attr('file_name').replace('.jsonl', ''); |
| 660 | 660 | |
| 661 | 661 | if (!fileName) { |
| 662 | 662 | return; |
| @@ -77,13 +77,12 @@ function getBackupFunction(handle) { | ||
| 77 | 77 | } |
| 78 | 78 | |
| 79 | 79 | /** |
| 80 | 80 | * Gets a preview message from an array ofa chat messagesmessage string. |
| 81 | 81 | * @param {Array<Object>string} messages[lastMessage] - Array of chat messages, each withThe amessage 'mes'to propertytruncate |
| 82 | 82 | * @returns {string} A truncated preview of the last message or empty string if no messages |
| 83 | 83 | */ |
| 84 | 84 | function getPreviewMessage(messageslastMessage) { |
| 85 | 85 | const strlen = 400; |
| 86 | - const lastMessage = messages[messages.length - 1]?.mes; | |
| 87 | 86 | |
| 88 | 87 | if (!lastMessage) { |
| 89 | 88 | return ''; |
| @@ -341,8 +340,9 @@ async function checkChatIntegrity(filePath, integritySlug) { | ||
| 341 | 340 | * @property {string} [file_size] - The size of the chat file in a human-readable format |
| 342 | 341 | * @property {number} [chat_items] - The number of chat items in the file |
| 343 | 342 | * @property {string} [mes] - The last message in the chat |
| 344 | 343 | * @property {number|string} [last_mes] - The timestamp of the last message |
| 345 | 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 | 350 | * @param {string} pathToFile - Path to the chat file |
| 351 | 351 | * @param {object} additionalData - Additional data to include in the result |
| 352 | 352 | * @param {boolean} withMetadata - Whether to read chat metadata |
| 353 | + * @param {ChatMatchFunction|null} matcher - Optional function to match messages | |
| 353 | 354 | * @returns {Promise<ChatInfo>} |
| 355 | + * | |
| 356 | + * @typedef {(text: string) => boolean} ChatMatchFunction | |
| 354 | 357 | */ |
| 355 | 358 | export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false, matcher = null) { |
| 356 | 359 | return new Promise(async (res) => { |
| 357 | 360 | const parsedPath = path.parse(pathToFile); |
| 358 | 361 | const stats = await fs.promises.stat(pathToFile); |
| 362 | + const hasMatcher = (typeof matcher === 'function'); | |
| 359 | 363 | |
| 360 | 364 | const chatData = { |
| 365 | + match: false, | |
| 361 | 366 | file_id: parsedPath.name, |
| 362 | 367 | file_name: parsedPath.base, |
| 363 | 368 | file_size: formatBytes(stats.size), |
| @@ -380,6 +385,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata | ||
| 380 | 385 | |
| 381 | 386 | let lastLine; |
| 382 | 387 | let itemCounter = 0; |
| 388 | + let hasAnyMatch = false; | |
| 383 | 389 | rl.on('line', (line) => { |
| 384 | 390 | if (withMetadata && itemCounter === 0) { |
| 385 | 391 | const jsonData = tryParse(line); |
| @@ -387,6 +393,13 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata | ||
| 387 | 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 | 403 | itemCounter++; |
| 391 | 404 | lastLine = line; |
| 392 | 405 | }); |
| @@ -399,6 +412,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata | ||
| 399 | 412 | chatData.chat_items = (itemCounter - 1); |
| 400 | 413 | chatData.mes = jsonData.mes || '[The message is empty]'; |
| 401 | 414 | chatData.last_mes = jsonData.send_date || new Date(Math.round(stats.mtimeMs)).toISOString(); |
| 415 | + chatData.match = hasMatcher ? hasAnyMatch : true; | |
| 402 | 416 | |
| 403 | 417 | res(chatData); |
| 404 | 418 | } else { |
| @@ -832,16 +846,18 @@ router.post('/group/save', async function (request, response) { | ||
| 832 | 846 | } |
| 833 | 847 | }); |
| 834 | 848 | |
| 835 | 849 | router.post('/search', validateAvatarUrlMiddleware, async function (request, response) { |
| 836 | 850 | try { |
| 837 | 851 | const { query, avatar_url, group_id } = request.body; |
| 852 | + | |
| 853 | + /** @type {string[]} */ | |
| 838 | 854 | let chatFiles = []; |
| 839 | 855 | |
| 840 | 856 | if (group_id) { |
| 841 | 857 | // Find group's chat IDs first |
| 842 | 858 | const groupDir = path.join(request.user.directories.groups); |
| 843 | 859 | const groupFiles = fs.readdirSync(groupDir) |
| 844 | 860 | .filter(file => filepath.endsWithextname(file) === '.json')); |
| 845 | 861 | |
| 846 | 862 | let targetGroup; |
| 847 | 863 | for (const groupFile of groupFiles) { |
| @@ -856,24 +872,15 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response) | ||
| 856 | 872 | } |
| 857 | 873 | } |
| 858 | 874 | |
| 859 | 875 | if (!Array.isArray(targetGroup?.chats)) { |
| 860 | 876 | return response.send([]); |
| 861 | 877 | } |
| 862 | 878 | |
| 863 | 879 | // Find group chat files for given group ID |
| 864 | 880 | const groupChatsDir = path.join(request.user.directories.groupChats); |
| 865 | 881 | chatFiles = targetGroup.chats |
| 866 | 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 | 884 | } else { |
| 878 | 885 | // Regular character chat directory |
| 879 | 886 | const character_name = avatar_url.replace('.png', ''); |
| @@ -884,64 +891,60 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response) | ||
| 884 | 891 | } |
| 885 | 892 | |
| 886 | 893 | chatFiles = fs.readdirSync(directoryPath) |
| 887 | 894 | .filter(file => filepath.endsWithextname(file) === '.jsonl')) |
| 888 | 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 | 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 | 925 | // If noSkip searchcorrupted query,or justinvalid returnchat metadatafiles |
| 914 | 926 | 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 | - }); | |
| 922 | 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 | 937 | results.push({ |
| 932 | 938 | file_name: chatFilechatInfo.file_namefile_id, |
| 933 | 939 | file_size: chatFilechatInfo.file_size, |
| 934 | 940 | message_count: messageschatInfo.lengthchat_items, |
| 935 | 941 | last_mes: lastMesDatechatInfo.last_mes, |
| 936 | 942 | preview_message: getPreviewMessage(messageschatInfo.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 | 947 | return response.send(results); |
| 944 | - | |
| 945 | 948 | } catch (error) { |
| 946 | 949 | console.error('Chat search error:', error); |
| 947 | 950 | return response.status(500).json({ error: 'Search failed' }); |