Merge pull request #3029 from P3il4/staging Optimize chat manager logic

b837c482fc4ddff38055c55b0807083768931d47

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
5 files changed, +211 -113Showing whitespace changes
public/index.html+5 -8
@@ -5461,7 +5461,7 @@
5461 </div>5461 </div>
5462 <div id="selectChatPopupHeaderText" class="TxtLrgBoldCenter">5462 <div id="selectChatPopupHeaderText" class="TxtLrgBoldCenter">
5463 <span id="ChatHistoryCharName"></span><span data-i18n="Chat History">Chat History</span>5463 <span id="ChatHistoryCharName"></span><span data-i18n="Chat History">Chat History</span>
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>5464 <a href="https://docs.sillytavern.app/usage/core-concepts/chatfilemanagement/" class="notes-link" target="_blank"><span class="fa-solid fa-circle-question note-link-span"></span></a>
5465 </div>5465 </div>
5466 <div id="newChatFromManageScreenButton" class="menu_button menu_button_icon">5466 <div id="newChatFromManageScreenButton" class="menu_button menu_button_icon">
5467 <i class="fa-solid fa-plus"></i>5467 <i class="fa-solid fa-plus"></i>
@@ -5471,13 +5471,10 @@
5471 <i class="fa-solid fa-file-import"></i>5471 <i class="fa-solid fa-file-import"></i>
5472 <span data-i18n="Import Chat">Import Chat</span>5472 <span data-i18n="Import Chat">Import Chat</span>
5473 </div>5473 </div>
5474 <input type="text" id="select_chat_search" class="text_pole flex1" data-i18n="[placeholder]Search..." placeholder="Search..." autocomplete="off">5474 <input type="search" id="select_chat_search" class="text_pole flex1" data-i18n="[placeholder]Search..." placeholder="Search..." autocomplete="off">
5475 <div id="select_chat_cross" class="opacity50p hoverglow fa-solid fa-circle-xmark fontsize120p" alt="Close Past Chat Popup"></div>5475 <div id="select_chat_cross" class="opacity50p hoverglow fa-solid fa-circle-xmark fontsize120p" alt="Close Past Chat Popup"></div>
5476 </div>5476 </div>
5477 <div id="select_chat_div"></div>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 </div>5478 </div>
5482 </div>5479 </div>
5483 <div id="background_template" class="template_element">5480 <div id="background_template" class="template_element">
@@ -5550,11 +5547,11 @@
5550 <div class="select_chat_block_wrapper flex-container">5547 <div class="select_chat_block_wrapper flex-container">
5551 <div class="select_chat_block wide100p flex-container" file_name="">5548 <div class="select_chat_block wide100p flex-container" file_name="">
5552 <div id="select_chat_name_wrapper" class="flex-container alignitemscenter justifySpaceBetween wide100p">5549 <div id="select_chat_name_wrapper" class="flex-container alignitemscenter justifySpaceBetween wide100p">
5553 <div>5550 <div class="flex-container alignItemsCenter">
5554 <small class="select_chat_block_filename select_chat_block_filename_item"></small>5551 <small class="select_chat_block_filename select_chat_block_filename_item"></small>
5555 <div title="Rename chat file" class="renameChatButton hoverglow opacity50p fa-solid fa-pencil" data-i18n="[title]Rename chat file"></div>5552 <div title="Rename chat file" class="renameChatButton hoverglow opacity50p fa-solid fa-pencil fa-sm" data-i18n="[title]Rename chat file"></div>
5556 </div>5553 </div>
5557 <div class="flex-container gap10px">5554 <div class="flex-container gap10px alignItemsCenter">
5558 <div class="select_chat_info flex-container">5555 <div class="select_chat_info flex-container">
5559 <small class="chat_messages_date select_chat_block_filename_item"></small>5556 <small class="chat_messages_date select_chat_block_filename_item"></small>
5560 <small class="chat_file_size select_chat_block_filename_item"></small>5557 <small class="chat_file_size select_chat_block_filename_item"></small>
public/script.js+49 -89
@@ -7026,99 +7026,15 @@ export async function displayPastChats() {
7026 $('#select_chat_div').empty();7026 $('#select_chat_div').empty();
7027 $('#select_chat_search').val('').off('input');7027 $('#select_chat_search').val('').off('input');
70287028
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 const chatDetails = getCurrentChatDetails();7029 const chatDetails = getCurrentChatDetails();
7037 const group = chatDetails.group;
7038 const currentChat = chatDetails.sessionName;7030 const currentChat = chatDetails.sessionName;
7039 const displayName = chatDetails.characterName;7031 const displayName = chatDetails.characterName;
7040 const avatarImg = chatDetails.avatarImgURL;7032 const avatarImg = chatDetails.avatarImgURL;
70417033
7042 const rawChats = await getChatsFromFiles(data, selected_group);7034 await displayChats('', currentChat, displayName, avatarImg, 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
71197035
7120 const debouncedDisplay = debounce((searchQuery) => {7036 const debouncedDisplay = debounce((searchQuery) => {
7121 displayChats(searchQuery);7037 displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group);
7122 });7038 });
71237039
7124 // Define the search input listener7040 // Define the search input listener
@@ -7136,6 +7052,53 @@ export async function displayPastChats() {
7136 }, 200);7052 }, 200);
7137}7053}
71387054
7055async 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
7139export function selectRightMenuWithAnimation(selectedMenuId) {7102export function selectRightMenuWithAnimation(selectedMenuId) {
7140 const displayModes = {7103 const displayModes = {
7141 'rm_group_chats_block': 'flex',7104 'rm_group_chats_block': 'flex',
@@ -10418,8 +10381,6 @@ jQuery(async function () {
10418 easing: animation_easing,10381 easing: animation_easing,
10419 });10382 });
10420 setTimeout(function () { $('#shadow_select_chat_popup').css('display', 'none'); }, animation_duration);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 });
1042410385
10425 if (navigator.clipboard === undefined) {10386 if (navigator.clipboard === undefined) {
@@ -10819,7 +10780,6 @@ jQuery(async function () {
10819 var formData = new FormData($('#form_import_chat').get(0));10780 var formData = new FormData($('#form_import_chat').get(0));
10820 formData.append('user_name', name1);10781 formData.append('user_name', name1);
10821 $('#select_chat_div').html('');10782 $('#select_chat_div').html('');
10822 $('#load_select_chat_div').css('display', 'block');
1082310783
10824 if (selected_group) {10784 if (selected_group) {
10825 await importGroupChat(formData);10785 await importGroupChat(formData);
public/scripts/bookmarks.js+0 -1
@@ -636,7 +636,6 @@ export function initBookmarks() {
636 }636 }
637637
638 $('#shadow_select_chat_popup').css('display', 'none');638 $('#shadow_select_chat_popup').css('display', 'none');
639 $('#load_select_chat_div').css('display', 'block');
640 });639 });
641640
642 $(document).on('click', '.mes_create_bookmark', async function () {641 $(document).on('click', '.mes_create_bookmark', async function () {
public/style.css+10 -15
@@ -4244,8 +4244,8 @@ h5 {
4244}4244}
42454245
4246#select_chat_popup {4246#select_chat_popup {
4247 display: grid;4247 display: flex;
4248 grid-template-rows: auto auto;4248 flex-direction: column;
4249 max-width: var(--sheldWidth);4249 max-width: var(--sheldWidth);
4250 height: min-content;4250 height: min-content;
4251 max-height: calc(100vh - var(--topBarBlockSize));4251 max-height: calc(100vh - var(--topBarBlockSize));
@@ -4265,7 +4265,7 @@ h5 {
4265 padding: 10px;4265 padding: 10px;
4266 background-color: var(--SmartThemeBlurTintColor);4266 background-color: var(--SmartThemeBlurTintColor);
4267 border-radius: 10px;4267 border-radius: 10px;
4268 overflow-y: auto;4268 overflow-y: hidden;
4269 border: 1px solid var(--SmartThemeBorderColor);4269 border: 1px solid var(--SmartThemeBorderColor);
4270}4270}
42714271
@@ -4273,20 +4273,10 @@ h5 {
4273 cursor: pointer;4273 cursor: pointer;
4274}4274}
42754275
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#select_chat_div {4276#select_chat_div {
4288 padding: 0;4277 padding: 0;
4289 height: min-content;4278 height: 100%;
4279 overflow-y: auto;
4290}4280}
42914281
4292#select_chat_div hr {4282#select_chat_div hr {
@@ -4333,6 +4323,11 @@ h5 {
43334323
4334.select_chat_block_mes {4324.select_chat_block_mes {
4335 font-size: calc(var(--mainFontSize) - .25rem);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}
43374332
4338.PastChat_cross {4333.PastChat_cross {
src/endpoints/chats.js+147 -0
@@ -52,6 +52,37 @@ function getBackupFunction(handle) {
52 return backupFunctions.get(handle);52 return backupFunctions.get(handle);
53}53}
5454
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 */
60function 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 */
73function 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
55process.on('exit', () => {86process.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
551router.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});