Merge pull request #3029 from P3il4/staging Optimize chat manager logic
Signed| @@ -5461,7 +5461,7 @@ | ||
| 5461 | 5461 | </div> |
| 5462 | 5462 | <div id="selectChatPopupHeaderText" class="TxtLrgBoldCenter"> |
| 5463 | 5463 | <span id="ChatHistoryCharName"></span><span data-i18n="Chat History">Chat History</span> |
| 5464 | 5464 | <a href="https://docs.sillytavern.app/usage/core-concepts/chatfilemanagement/#chat-import" class="notes-link" target="_blank"><span class="fa-solid fa-circle-question note-link-span"></span></a> |
| 5465 | 5465 | </div> |
| 5466 | 5466 | <div id="newChatFromManageScreenButton" class="menu_button menu_button_icon"> |
| 5467 | 5467 | <i class="fa-solid fa-plus"></i> |
| @@ -5471,13 +5471,10 @@ | ||
| 5471 | 5471 | <i class="fa-solid fa-file-import"></i> |
| 5472 | 5472 | <span data-i18n="Import Chat">Import Chat</span> |
| 5473 | 5473 | </div> |
| 5474 | 5474 | <input type="textsearch" id="select_chat_search" class="text_pole flex1" data-i18n="[placeholder]Search..." placeholder="Search..." autocomplete="off"> |
| 5475 | 5475 | <div id="select_chat_cross" class="opacity50p hoverglow fa-solid fa-circle-xmark fontsize120p" alt="Close Past Chat Popup"></div> |
| 5476 | 5476 | </div> |
| 5477 | 5477 | <div id="select_chat_div"></div> |
| 5478 | - <div id="load_select_chat_div"> | |
| 5479 | - <div class="fa-solid fa-hourglass fa-spin"></div> | |
| 5480 | - </div> | |
| 5481 | 5478 | </div> |
| 5482 | 5479 | </div> |
| 5483 | 5480 | <div id="background_template" class="template_element"> |
| @@ -5550,11 +5547,11 @@ | ||
| 5550 | 5547 | <div class="select_chat_block_wrapper flex-container"> |
| 5551 | 5548 | <div class="select_chat_block wide100p flex-container" file_name=""> |
| 5552 | 5549 | <div id="select_chat_name_wrapper" class="flex-container alignitemscenter justifySpaceBetween wide100p"> |
| 5553 | - <div> | |
| 5550 | + <div class="flex-container alignItemsCenter"> | |
| 5554 | 5551 | <small class="select_chat_block_filename select_chat_block_filename_item"></small> |
| 5555 | 5552 | <div title="Rename chat file" class="renameChatButton hoverglow opacity50p fa-solid fa-pencil fa-sm" data-i18n="[title]Rename chat file"></div> |
| 5556 | 5553 | </div> |
| 5557 | 5554 | <div class="flex-container gap10px alignItemsCenter"> |
| 5558 | 5555 | <div class="select_chat_info flex-container"> |
| 5559 | 5556 | <small class="chat_messages_date select_chat_block_filename_item"></small> |
| 5560 | 5557 | <small class="chat_file_size select_chat_block_filename_item"></small> |
| @@ -7026,99 +7026,15 @@ export async function displayPastChats() { | ||
| 7026 | 7026 | $('#select_chat_div').empty(); |
| 7027 | 7027 | $('#select_chat_search').val('').off('input'); |
| 7028 | 7028 | |
| 7029 | - const data = await (selected_group ? getGroupPastChats(selected_group) : getPastCharacterChats()); | |
| 7030 | - | |
| 7031 | - if (!data) { | |
| 7032 | - toastr.error(t`Could not load chat data. Try reloading the page.`); | |
| 7033 | - return; | |
| 7034 | - } | |
| 7035 | - | |
| 7036 | 7029 | const chatDetails = getCurrentChatDetails(); |
| 7037 | - const group = chatDetails.group; | |
| 7038 | 7030 | const currentChat = chatDetails.sessionName; |
| 7039 | 7031 | const displayName = chatDetails.characterName; |
| 7040 | 7032 | const avatarImg = chatDetails.avatarImgURL; |
| 7041 | 7033 | |
| 7042 | 7034 | constawait rawChatsdisplayChats('', =currentChat, awaitdisplayName, getChatsFromFiles(dataavatarImg, selected_group); |
| 7043 | - | |
| 7044 | - // Sort by last message date descending | |
| 7045 | - data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))); | |
| 7046 | - console.log(data); | |
| 7047 | - $('#load_select_chat_div').css('display', 'none'); | |
| 7048 | - $('#ChatHistoryCharName').text(`${displayName}'s `); | |
| 7049 | - | |
| 7050 | - const displayChats = (searchQuery) => { | |
| 7051 | - $('#select_chat_div').empty(); // Clear the current chats before appending filtered chats | |
| 7052 | - | |
| 7053 | - const filteredData = data.filter(chat => { | |
| 7054 | - const fileName = chat['file_name']; | |
| 7055 | - const chatContent = rawChats[fileName]; | |
| 7056 | - | |
| 7057 | - // Make sure empty chats are displayed when there is no search query | |
| 7058 | - if (Array.isArray(chatContent) && !chatContent.length && !searchQuery) { | |
| 7059 | - return true; | |
| 7060 | - } | |
| 7061 | - | |
| 7062 | - // // Uncomment this to return to old behavior (classical full-substring search). | |
| 7063 | - // return chatContent && Object.values(chatContent).some(message => message?.mes?.toLowerCase()?.includes(searchQuery.toLowerCase())); | |
| 7064 | - | |
| 7065 | - // Fragment search a.k.a. swoop (as in `helm-swoop` in the Helm package of Emacs). | |
| 7066 | - // Split a `query` {string} into its fragments {string[]}. | |
| 7067 | - function makeQueryFragments(query) { | |
| 7068 | - let fragments = query.trim().split(/\s+/).map(str => str.trim().toLowerCase()).filter(onlyUnique); | |
| 7069 | - // fragments = fragments.filter( function(str) { return str.length >= 3; } ); // Helm does this, but perhaps better if we don't. | |
| 7070 | - return fragments; | |
| 7071 | - } | |
| 7072 | - // Check whether `text` {string} includes all of the `fragments` {string[]}. | |
| 7073 | - function matchFragments(fragments, text) { | |
| 7074 | - if (!text || !text.toLowerCase) return false; | |
| 7075 | - return fragments.every(item => text.toLowerCase().includes(item)); | |
| 7076 | - } | |
| 7077 | - const fragments = makeQueryFragments(searchQuery); | |
| 7078 | - // At least one chat message must match *all* the fragments. | |
| 7079 | - // Currently, this doesn't match if the fragment matches are distributed across several chat messages. | |
| 7080 | - return chatContent && Object.values(chatContent).some(message => matchFragments(fragments, message?.mes)); | |
| 7081 | - }); | |
| 7082 | - | |
| 7083 | - console.debug(filteredData); | |
| 7084 | - for (const value of filteredData.values()) { | |
| 7085 | - let strlen = 300; | |
| 7086 | - let mes = value['mes']; | |
| 7087 | - | |
| 7088 | - if (mes !== undefined) { | |
| 7089 | - if (mes.length > strlen) { | |
| 7090 | - mes = '...' + mes.substring(mes.length - strlen); | |
| 7091 | - } | |
| 7092 | - const fileSize = value['file_size']; | |
| 7093 | - const fileName = value['file_name']; | |
| 7094 | - const chatItems = rawChats[fileName].length; | |
| 7095 | - const timestamp = timestampToMoment(value['last_mes']).format('lll'); | |
| 7096 | - const template = $('#past_chat_template .select_chat_block_wrapper').clone(); | |
| 7097 | - template.find('.select_chat_block').attr('file_name', fileName); | |
| 7098 | - template.find('.avatar img').attr('src', avatarImg); | |
| 7099 | - template.find('.select_chat_block_filename').text(fileName); | |
| 7100 | - template.find('.chat_file_size').text(`(${fileSize},`); | |
| 7101 | - template.find('.chat_messages_num').text(`${chatItems}💬)`); | |
| 7102 | - template.find('.select_chat_block_mes').text(mes); | |
| 7103 | - template.find('.PastChat_cross').attr('file_name', fileName); | |
| 7104 | - template.find('.chat_messages_date').text(timestamp); | |
| 7105 | - | |
| 7106 | - if (selected_group) { | |
| 7107 | - template.find('.avatar img').replaceWith(getGroupAvatar(group)); | |
| 7108 | - } | |
| 7109 | - | |
| 7110 | - $('#select_chat_div').append(template); | |
| 7111 | - | |
| 7112 | - if (currentChat === fileName.toString().replace('.jsonl', '')) { | |
| 7113 | - $('#select_chat_div').find('.select_chat_block:last').attr('highlight', String(true)); | |
| 7114 | - } | |
| 7115 | - } | |
| 7116 | - } | |
| 7117 | - }; | |
| 7118 | - displayChats(''); // Display all by default | |
| 7119 | 7035 | |
| 7120 | 7036 | const debouncedDisplay = debounce((searchQuery) => { |
| 7121 | 7037 | displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group); |
| 7122 | 7038 | }); |
| 7123 | 7039 | |
| 7124 | 7040 | // Define the search input listener |
| @@ -7136,6 +7052,53 @@ export async function displayPastChats() { | ||
| 7136 | 7052 | }, 200); |
| 7137 | 7053 | } |
| 7138 | 7054 | |
| 7055 | +async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group) { | |
| 7056 | + try { | |
| 7057 | + const trimExtension = (fileName) => String(fileName).replace('.jsonl', ''); | |
| 7058 | + | |
| 7059 | + const response = await fetch('/api/chats/search', { | |
| 7060 | + method: 'POST', | |
| 7061 | + headers: getRequestHeaders(), | |
| 7062 | + body: JSON.stringify({ | |
| 7063 | + query: searchQuery, | |
| 7064 | + avatar_url: selected_group ? null : characters[this_chid].avatar, | |
| 7065 | + group_id: selected_group || null, | |
| 7066 | + }), | |
| 7067 | + }); | |
| 7068 | + | |
| 7069 | + if (!response.ok) { | |
| 7070 | + throw new Error('Search failed'); | |
| 7071 | + } | |
| 7072 | + | |
| 7073 | + const filteredData = await response.json(); | |
| 7074 | + $('#select_chat_div').empty(); | |
| 7075 | + | |
| 7076 | + filteredData.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))); | |
| 7077 | + | |
| 7078 | + for (const chat of filteredData) { | |
| 7079 | + const isSelected = trimExtension(currentChat) === trimExtension(chat.file_name); | |
| 7080 | + const template = $('#past_chat_template .select_chat_block_wrapper').clone(); | |
| 7081 | + template.find('.select_chat_block').attr('file_name', chat.file_name); | |
| 7082 | + template.find('.avatar img').attr('src', avatarImg); | |
| 7083 | + template.find('.select_chat_block_filename').text(chat.file_name); | |
| 7084 | + template.find('.chat_file_size').text(`(${chat.file_size},`); | |
| 7085 | + template.find('.chat_messages_num').text(`${chat.message_count} 💬)`); | |
| 7086 | + template.find('.select_chat_block_mes').text(chat.preview_message); | |
| 7087 | + template.find('.PastChat_cross').attr('file_name', chat.file_name); | |
| 7088 | + template.find('.chat_messages_date').text(timestampToMoment(chat.last_mes).format('lll')); | |
| 7089 | + | |
| 7090 | + if (isSelected) { | |
| 7091 | + template.find('.select_chat_block').attr('highlight', String(true)); | |
| 7092 | + } | |
| 7093 | + | |
| 7094 | + $('#select_chat_div').append(template); | |
| 7095 | + } | |
| 7096 | + } catch (error) { | |
| 7097 | + console.error('Error loading chats:', error); | |
| 7098 | + toastr.error('Could not load chat data. Try reloading the page.'); | |
| 7099 | + } | |
| 7100 | +} | |
| 7101 | + | |
| 7139 | 7102 | export function selectRightMenuWithAnimation(selectedMenuId) { |
| 7140 | 7103 | const displayModes = { |
| 7141 | 7104 | 'rm_group_chats_block': 'flex', |
| @@ -10418,8 +10381,6 @@ jQuery(async function () { | ||
| 10418 | 10381 | easing: animation_easing, |
| 10419 | 10382 | }); |
| 10420 | 10383 | setTimeout(function () { $('#shadow_select_chat_popup').css('display', 'none'); }, animation_duration); |
| 10421 | - //$("#shadow_select_chat_popup").css("display", "none"); | |
| 10422 | - $('#load_select_chat_div').css('display', 'block'); | |
| 10423 | 10384 | }); |
| 10424 | 10385 | |
| 10425 | 10386 | if (navigator.clipboard === undefined) { |
| @@ -10819,7 +10780,6 @@ jQuery(async function () { | ||
| 10819 | 10780 | var formData = new FormData($('#form_import_chat').get(0)); |
| 10820 | 10781 | formData.append('user_name', name1); |
| 10821 | 10782 | $('#select_chat_div').html(''); |
| 10822 | - $('#load_select_chat_div').css('display', 'block'); | |
| 10823 | 10783 | |
| 10824 | 10784 | if (selected_group) { |
| 10825 | 10785 | await importGroupChat(formData); |
| @@ -636,7 +636,6 @@ export function initBookmarks() { | ||
| 636 | 636 | } |
| 637 | 637 | |
| 638 | 638 | $('#shadow_select_chat_popup').css('display', 'none'); |
| 639 | - $('#load_select_chat_div').css('display', 'block'); | |
| 640 | 639 | }); |
| 641 | 640 | |
| 642 | 641 | $(document).on('click', '.mes_create_bookmark', async function () { |
| @@ -4244,8 +4244,8 @@ h5 { | ||
| 4244 | 4244 | } |
| 4245 | 4245 | |
| 4246 | 4246 | #select_chat_popup { |
| 4247 | 4247 | display: gridflex; |
| 4248 | 4248 | grid-templateflex-rowsdirection: auto autocolumn; |
| 4249 | 4249 | max-width: var(--sheldWidth); |
| 4250 | 4250 | height: min-content; |
| 4251 | 4251 | max-height: calc(100vh - var(--topBarBlockSize)); |
| @@ -4265,7 +4265,7 @@ h5 { | ||
| 4265 | 4265 | padding: 10px; |
| 4266 | 4266 | background-color: var(--SmartThemeBlurTintColor); |
| 4267 | 4267 | border-radius: 10px; |
| 4268 | 4268 | overflow-y: autohidden; |
| 4269 | 4269 | border: 1px solid var(--SmartThemeBorderColor); |
| 4270 | 4270 | } |
| 4271 | 4271 | |
| @@ -4273,20 +4273,10 @@ h5 { | ||
| 4273 | 4273 | cursor: pointer; |
| 4274 | 4274 | } |
| 4275 | 4275 | |
| 4276 | -#load_select_chat_div { | |
| 4277 | - position: absolute; | |
| 4278 | - bottom: 154px; | |
| 4279 | - left: 174px; | |
| 4280 | -} | |
| 4281 | - | |
| 4282 | -#load_select_chat_div img { | |
| 4283 | - width: 80px; | |
| 4284 | - height: 80px; | |
| 4285 | -} | |
| 4286 | - | |
| 4287 | 4276 | #select_chat_div { |
| 4288 | 4277 | padding: 0; |
| 4289 | 4278 | height: min-content100%; |
| 4279 | + overflow-y: auto; | |
| 4290 | 4280 | } |
| 4291 | 4281 | |
| 4292 | 4282 | #select_chat_div hr { |
| @@ -4333,6 +4323,11 @@ h5 { | ||
| 4333 | 4323 | |
| 4334 | 4324 | .select_chat_block_mes { |
| 4335 | 4325 | font-size: calc(var(--mainFontSize) - .25rem); |
| 4326 | + display: -webkit-box; | |
| 4327 | + -webkit-line-clamp: 3; | |
| 4328 | + -webkit-box-orient: vertical; | |
| 4329 | + line-clamp: 3; | |
| 4330 | + overflow: hidden; | |
| 4336 | 4331 | } |
| 4337 | 4332 | |
| 4338 | 4333 | .PastChat_cross { |
| @@ -52,6 +52,37 @@ function getBackupFunction(handle) { | ||
| 52 | 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 = 400; | |
| 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 | 86 | process.on('exit', () => { |
| 56 | 87 | for (const func of backupFunctions.values()) { |
| 57 | 88 | func.flush(); |
| @@ -516,3 +547,119 @@ router.post('/group/save', jsonParser, (request, response) => { | ||
| 516 | 547 | getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData); |
| 517 | 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 && typeof x.mes === 'string'); | |
| 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 | +}); | |