optimize chat manager logic
| @@ -7025,99 +7025,15 @@ export async function displayPastChats() { | |||
| 7025 | $('#select_chat_div').empty(); | 7025 | $('#select_chat_div').empty(); |
| 7026 | $('#select_chat_search').val('').off('input'); | 7026 | $('#select_chat_search').val('').off('input'); |
| 7027 | 7027 | ||
| 7028 | const data = await (selected_group ? getGroupPastChats(selected_group) : getPastCharacterChats()); | ||
| 7029 | |||
| 7030 | if (!data) { | ||
| 7031 | toastr.error(t`Could not load chat data. Try reloading the page.`); | ||
| 7032 | return; | ||
| 7033 | } | ||
| 7034 | |||
| 7035 | const chatDetails = getCurrentChatDetails(); | 7028 | const chatDetails = getCurrentChatDetails(); |
| 7036 | const group = chatDetails.group; | ||
| 7037 | const currentChat = chatDetails.sessionName; | 7029 | const currentChat = chatDetails.sessionName; |
| 7038 | const displayName = chatDetails.characterName; | 7030 | const displayName = chatDetails.characterName; |
| 7039 | const avatarImg = chatDetails.avatarImgURL; | 7031 | const avatarImg = chatDetails.avatarImgURL; |
| 7040 | 7032 | ||
| 7041 | const rawChats = await getChatsFromFiles(data, selected_group); | 7033 | await displayChats('', currentChat, displayName, avatarImg, selected_group); |
| 7042 | |||
| 7043 | // Sort by last message date descending | ||
| 7044 | data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))); | ||
| 7045 | console.log(data); | ||
| 7046 | $('#load_select_chat_div').css('display', 'none'); | ||
| 7047 | $('#ChatHistoryCharName').text(`${displayName}'s `); | ||
| 7048 | |||
| 7049 | const displayChats = (searchQuery) => { | ||
| 7050 | $('#select_chat_div').empty(); // Clear the current chats before appending filtered chats | ||
| 7051 | |||
| 7052 | const filteredData = data.filter(chat => { | ||
| 7053 | const fileName = chat['file_name']; | ||
| 7054 | const chatContent = rawChats[fileName]; | ||
| 7055 | |||
| 7056 | // Make sure empty chats are displayed when there is no search query | ||
| 7057 | if (Array.isArray(chatContent) && !chatContent.length && !searchQuery) { | ||
| 7058 | return true; | ||
| 7059 | } | ||
| 7060 | |||
| 7061 | // // Uncomment this to return to old behavior (classical full-substring search). | ||
| 7062 | // return chatContent && Object.values(chatContent).some(message => message?.mes?.toLowerCase()?.includes(searchQuery.toLowerCase())); | ||
| 7063 | |||
| 7064 | // Fragment search a.k.a. swoop (as in `helm-swoop` in the Helm package of Emacs). | ||
| 7065 | // Split a `query` {string} into its fragments {string[]}. | ||
| 7066 | function makeQueryFragments(query) { | ||
| 7067 | let fragments = query.trim().split(/\s+/).map(str => str.trim().toLowerCase()).filter(onlyUnique); | ||
| 7068 | // fragments = fragments.filter( function(str) { return str.length >= 3; } ); // Helm does this, but perhaps better if we don't. | ||
| 7069 | return fragments; | ||
| 7070 | } | ||
| 7071 | // Check whether `text` {string} includes all of the `fragments` {string[]}. | ||
| 7072 | function matchFragments(fragments, text) { | ||
| 7073 | if (!text || !text.toLowerCase) return false; | ||
| 7074 | return fragments.every(item => text.toLowerCase().includes(item)); | ||
| 7075 | } | ||
| 7076 | const fragments = makeQueryFragments(searchQuery); | ||
| 7077 | // At least one chat message must match *all* the fragments. | ||
| 7078 | // Currently, this doesn't match if the fragment matches are distributed across several chat messages. | ||
| 7079 | return chatContent && Object.values(chatContent).some(message => matchFragments(fragments, message?.mes)); | ||
| 7080 | }); | ||
| 7081 | |||
| 7082 | console.debug(filteredData); | ||
| 7083 | for (const value of filteredData.values()) { | ||
| 7084 | let strlen = 300; | ||
| 7085 | let mes = value['mes']; | ||
| 7086 | |||
| 7087 | if (mes !== undefined) { | ||
| 7088 | if (mes.length > strlen) { | ||
| 7089 | mes = '...' + mes.substring(mes.length - strlen); | ||
| 7090 | } | ||
| 7091 | const fileSize = value['file_size']; | ||
| 7092 | const fileName = value['file_name']; | ||
| 7093 | const chatItems = rawChats[fileName].length; | ||
| 7094 | const timestamp = timestampToMoment(value['last_mes']).format('lll'); | ||
| 7095 | const template = $('#past_chat_template .select_chat_block_wrapper').clone(); | ||
| 7096 | template.find('.select_chat_block').attr('file_name', fileName); | ||
| 7097 | template.find('.avatar img').attr('src', avatarImg); | ||
| 7098 | template.find('.select_chat_block_filename').text(fileName); | ||
| 7099 | template.find('.chat_file_size').text(`(${fileSize},`); | ||
| 7100 | template.find('.chat_messages_num').text(`${chatItems}💬)`); | ||
| 7101 | template.find('.select_chat_block_mes').text(mes); | ||
| 7102 | template.find('.PastChat_cross').attr('file_name', fileName); | ||
| 7103 | template.find('.chat_messages_date').text(timestamp); | ||
| 7104 | |||
| 7105 | if (selected_group) { | ||
| 7106 | template.find('.avatar img').replaceWith(getGroupAvatar(group)); | ||
| 7107 | } | ||
| 7108 | |||
| 7109 | $('#select_chat_div').append(template); | ||
| 7110 | |||
| 7111 | if (currentChat === fileName.toString().replace('.jsonl', '')) { | ||
| 7112 | $('#select_chat_div').find('.select_chat_block:last').attr('highlight', String(true)); | ||
| 7113 | } | ||
| 7114 | } | ||
| 7115 | } | ||
| 7116 | }; | ||
| 7117 | displayChats(''); // Display all by default | ||
| 7118 | 7034 | ||
| 7119 | const debouncedDisplay = debounce((searchQuery) => { | 7035 | const debouncedDisplay = debounce((searchQuery) => { |
| 7120 | displayChats(searchQuery); | 7036 | displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group); |
| 7121 | }); | 7037 | }); |
| 7122 | 7038 | ||
| 7123 | // Define the search input listener | 7039 | // Define the search input listener |
| @@ -7135,6 +7051,48 @@ export async function displayPastChats() { | |||
| 7135 | }, 200); | 7051 | }, 200); |
| 7136 | } | 7052 | } |
| 7137 | 7053 | ||
| 7054 | async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group) { | ||
| 7055 | try { | ||
| 7056 | const response = await fetch('/api/chats/search', { | ||
| 7057 | method: 'POST', | ||
| 7058 | headers: getRequestHeaders(), | ||
| 7059 | body: JSON.stringify({ | ||
| 7060 | query: searchQuery, | ||
| 7061 | avatar_url: selected_group ? null : characters[this_chid].avatar, | ||
| 7062 | group_id: selected_group || null | ||
| 7063 | }), | ||
| 7064 | }); | ||
| 7065 | |||
| 7066 | if (!response.ok) { | ||
| 7067 | throw new Error('Search failed'); | ||
| 7068 | } | ||
| 7069 | |||
| 7070 | const filteredData = await response.json(); | ||
| 7071 | $('#select_chat_div').empty(); | ||
| 7072 | |||
| 7073 | for (const chat of filteredData) { | ||
| 7074 | const template = $('#past_chat_template .select_chat_block_wrapper').clone(); | ||
| 7075 | template.find('.select_chat_block').attr('file_name', chat.file_name); | ||
| 7076 | template.find('.avatar img').attr('src', avatarImg); | ||
| 7077 | template.find('.select_chat_block_filename').text(chat.file_name); | ||
| 7078 | template.find('.chat_file_size').text(`(${chat.file_size},`); | ||
| 7079 | template.find('.chat_messages_num').text(`${chat.message_count}💬)`); | ||
| 7080 | template.find('.select_chat_block_mes').text(chat.preview_message); | ||
| 7081 | template.find('.PastChat_cross').attr('file_name', chat.file_name); | ||
| 7082 | template.find('.chat_messages_date').text(timestampToMoment(chat.last_mes).format('lll')); | ||
| 7083 | |||
| 7084 | $('#select_chat_div').append(template); | ||
| 7085 | |||
| 7086 | if (currentChat === chat.file_name) { | ||
| 7087 | $('#select_chat_div').find('.select_chat_block:last').attr('highlight', String(true)); | ||
| 7088 | } | ||
| 7089 | } | ||
| 7090 | } catch (error) { | ||
| 7091 | console.error('Error loading chats:', error); | ||
| 7092 | toastr.error('Could not load chat data. Try reloading the page.'); | ||
| 7093 | } | ||
| 7094 | } | ||
| 7095 | |||
| 7138 | export function selectRightMenuWithAnimation(selectedMenuId) { | 7096 | export function selectRightMenuWithAnimation(selectedMenuId) { |
| 7139 | const displayModes = { | 7097 | const displayModes = { |
| 7140 | 'rm_group_chats_block': 'flex', | 7098 | 'rm_group_chats_block': 'flex', |
| @@ -10421,6 +10379,11 @@ jQuery(async function () { | |||
| 10421 | $('#load_select_chat_div').css('display', 'block'); | 10379 | $('#load_select_chat_div').css('display', 'block'); |
| 10422 | }); | 10380 | }); |
| 10423 | 10381 | ||
| 10382 | // not sure what that hourglass was for | ||
| 10383 | $('#option_select_chat').click(function () { | ||
| 10384 | $('#load_select_chat_div').css('display', 'none'); | ||
| 10385 | }); | ||
| 10386 | |||
| 10424 | if (navigator.clipboard === undefined) { | 10387 | if (navigator.clipboard === undefined) { |
| 10425 | // No clipboard support | 10388 | // No clipboard support |
| 10426 | $('.mes_copy').remove(); | 10389 | $('.mes_copy').remove(); |
| @@ -52,6 +52,37 @@ function getBackupFunction(handle) { | |||
| 52 | return backupFunctions.get(handle); | 52 | return backupFunctions.get(handle); |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | /** | ||
| 56 | * Formats a byte size into a human-readable string with units | ||
| 57 | * @param {number} bytes - The size in bytes to format | ||
| 58 | * @returns {string} The formatted string (e.g., "1.5 MB") | ||
| 59 | */ | ||
| 60 | function formatBytes(bytes) { | ||
| 61 | if (bytes === 0) return '0 B'; | ||
| 62 | const k = 1024; | ||
| 63 | const sizes = ['B', 'KB', 'MB', 'GB']; | ||
| 64 | const i = Math.floor(Math.log(bytes) / Math.log(k)); | ||
| 65 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | ||
| 66 | } | ||
| 67 | |||
| 68 | /** | ||
| 69 | * Gets a preview message from an array of chat messages | ||
| 70 | * @param {Array<Object>} messages - Array of chat messages, each with a 'mes' property | ||
| 71 | * @returns {string} A truncated preview of the last message or empty string if no messages | ||
| 72 | */ | ||
| 73 | function getPreviewMessage(messages) { | ||
| 74 | const strlen = 300; | ||
| 75 | const lastMessage = messages[messages.length - 1]?.mes; | ||
| 76 | |||
| 77 | if (!lastMessage) { | ||
| 78 | return ''; | ||
| 79 | } | ||
| 80 | |||
| 81 | return lastMessage.length > strlen | ||
| 82 | ? '...' + lastMessage.substring(lastMessage.length - strlen) | ||
| 83 | : lastMessage; | ||
| 84 | } | ||
| 85 | |||
| 55 | process.on('exit', () => { | 86 | process.on('exit', () => { |
| 56 | for (const func of backupFunctions.values()) { | 87 | for (const func of backupFunctions.values()) { |
| 57 | func.flush(); | 88 | func.flush(); |
| @@ -516,3 +547,119 @@ router.post('/group/save', jsonParser, (request, response) => { | |||
| 516 | getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData); | 547 | getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData); |
| 517 | return response.send({ ok: true }); | 548 | return response.send({ ok: true }); |
| 518 | }); | 549 | }); |
| 550 | |||
| 551 | router.post('/search', jsonParser, function (request, response) { | ||
| 552 | try { | ||
| 553 | const { query, avatar_url, group_id } = request.body; | ||
| 554 | let chatFiles = []; | ||
| 555 | |||
| 556 | if (group_id) { | ||
| 557 | // Find group's chat IDs first | ||
| 558 | const groupDir = path.join(request.user.directories.groups); | ||
| 559 | const groupFiles = fs.readdirSync(groupDir) | ||
| 560 | .filter(file => file.endsWith('.json')); | ||
| 561 | |||
| 562 | let targetGroup; | ||
| 563 | for (const groupFile of groupFiles) { | ||
| 564 | const groupData = JSON.parse(fs.readFileSync(path.join(groupDir, groupFile), 'utf8')); | ||
| 565 | if (groupData.id === group_id) { | ||
| 566 | targetGroup = groupData; | ||
| 567 | break; | ||
| 568 | } | ||
| 569 | } | ||
| 570 | |||
| 571 | if (!targetGroup?.chats) { | ||
| 572 | return response.send([]); | ||
| 573 | } | ||
| 574 | |||
| 575 | // Find group chat files for given group ID | ||
| 576 | const groupChatsDir = path.join(request.user.directories.groupChats); | ||
| 577 | chatFiles = targetGroup.chats | ||
| 578 | .map(chatId => { | ||
| 579 | const filePath = path.join(groupChatsDir, `${chatId}.jsonl`); | ||
| 580 | if (!fs.existsSync(filePath)) return null; | ||
| 581 | const stats = fs.statSync(filePath); | ||
| 582 | return { | ||
| 583 | file_name: chatId, | ||
| 584 | file_size: formatBytes(stats.size), | ||
| 585 | path: filePath, | ||
| 586 | }; | ||
| 587 | }) | ||
| 588 | .filter(x => x); | ||
| 589 | } else { | ||
| 590 | // Regular character chat directory | ||
| 591 | const character_name = avatar_url.replace('.png', ''); | ||
| 592 | const directoryPath = path.join(request.user.directories.chats, character_name); | ||
| 593 | |||
| 594 | if (!fs.existsSync(directoryPath)) { | ||
| 595 | return response.send([]); | ||
| 596 | } | ||
| 597 | |||
| 598 | chatFiles = fs.readdirSync(directoryPath) | ||
| 599 | .filter(file => file.endsWith('.jsonl')) | ||
| 600 | .map(fileName => { | ||
| 601 | const filePath = path.join(directoryPath, fileName); | ||
| 602 | const stats = fs.statSync(filePath); | ||
| 603 | return { | ||
| 604 | file_name: fileName, | ||
| 605 | file_size: formatBytes(stats.size), | ||
| 606 | path: filePath, | ||
| 607 | }; | ||
| 608 | }); | ||
| 609 | } | ||
| 610 | |||
| 611 | const results = []; | ||
| 612 | |||
| 613 | // Search logic | ||
| 614 | for (const chatFile of chatFiles) { | ||
| 615 | const data = fs.readFileSync(chatFile.path, 'utf8'); | ||
| 616 | const messages = data.split('\n') | ||
| 617 | .map(line => { try { return JSON.parse(line); } catch (_) { return null; } }) | ||
| 618 | .filter(x => x); | ||
| 619 | |||
| 620 | if (messages.length === 0) { | ||
| 621 | continue; | ||
| 622 | } | ||
| 623 | |||
| 624 | const lastMessage = messages[messages.length - 1]; | ||
| 625 | const lastMesDate = lastMessage?.send_date || new Date().toISOString(); | ||
| 626 | |||
| 627 | // If no search query, just return metadata | ||
| 628 | if (!query) { | ||
| 629 | results.push({ | ||
| 630 | file_name: chatFile.file_name, | ||
| 631 | file_size: chatFile.file_size, | ||
| 632 | message_count: messages.length, | ||
| 633 | last_mes: lastMesDate, | ||
| 634 | preview_message: getPreviewMessage(messages), | ||
| 635 | }); | ||
| 636 | continue; | ||
| 637 | } | ||
| 638 | |||
| 639 | // Search through messages | ||
| 640 | const fragments = query.trim().toLowerCase().split(/\s+/).filter(x => x); | ||
| 641 | const hasMatch = messages.some(message => { | ||
| 642 | const text = message?.mes?.toLowerCase(); | ||
| 643 | return text && fragments.every(fragment => text.includes(fragment)); | ||
| 644 | }); | ||
| 645 | |||
| 646 | if (hasMatch) { | ||
| 647 | results.push({ | ||
| 648 | file_name: chatFile.file_name, | ||
| 649 | file_size: chatFile.file_size, | ||
| 650 | message_count: messages.length, | ||
| 651 | last_mes: lastMesDate, | ||
| 652 | preview_message: getPreviewMessage(messages), | ||
| 653 | }); | ||
| 654 | } | ||
| 655 | } | ||
| 656 | |||
| 657 | // Sort by last message date descending | ||
| 658 | results.sort((a, b) => new Date(b.last_mes) - new Date(a.last_mes)); | ||
| 659 | return response.send(results); | ||
| 660 | |||
| 661 | } catch (error) { | ||
| 662 | console.error('Chat search error:', error); | ||
| 663 | return response.status(500).json({ error: 'Search failed' }); | ||
| 664 | } | ||
| 665 | }); | ||
| \ No newline at end of file | 665 | \ No newline at end of file | |