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 @@
54615461 </div>
54625462 <div id="selectChatPopupHeaderText" class="TxtLrgBoldCenter">
54635463 <span id="ChatHistoryCharName"></span><span data-i18n="Chat History">Chat History</span>
54645464 <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>
54655465 </div>
54665466 <div id="newChatFromManageScreenButton" class="menu_button menu_button_icon">
54675467 <i class="fa-solid fa-plus"></i>
@@ -5471,13 +5471,10 @@
54715471 <i class="fa-solid fa-file-import"></i>
54725472 <span data-i18n="Import Chat">Import Chat</span>
54735473 </div>
54745474 <input type="textsearch" id="select_chat_search" class="text_pole flex1" data-i18n="[placeholder]Search..." placeholder="Search..." autocomplete="off">
54755475 <div id="select_chat_cross" class="opacity50p hoverglow fa-solid fa-circle-xmark fontsize120p" alt="Close Past Chat Popup"></div>
54765476 </div>
54775477 <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>
54815478 </div>
54825479 </div>
54835480 <div id="background_template" class="template_element">
@@ -5550,11 +5547,11 @@
55505547 <div class="select_chat_block_wrapper flex-container">
55515548 <div class="select_chat_block wide100p flex-container" file_name="">
55525549 <div id="select_chat_name_wrapper" class="flex-container alignitemscenter justifySpaceBetween wide100p">
5553- <div>
5550+ <div class="flex-container alignItemsCenter">
55545551 <small class="select_chat_block_filename select_chat_block_filename_item"></small>
55555552 <div title="Rename chat file" class="renameChatButton hoverglow opacity50p fa-solid fa-pencil fa-sm" data-i18n="[title]Rename chat file"></div>
55565553 </div>
55575554 <div class="flex-container gap10px alignItemsCenter">
55585555 <div class="select_chat_info flex-container">
55595556 <small class="chat_messages_date select_chat_block_filename_item"></small>
55605557 <small class="chat_file_size select_chat_block_filename_item"></small>
public/script.js+49 -89
@@ -7026,99 +7026,15 @@ export async function displayPastChats() {
70267026 $('#select_chat_div').empty();
70277027 $('#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-
70367029 const chatDetails = getCurrentChatDetails();
7037- const group = chatDetails.group;
70387030 const currentChat = chatDetails.sessionName;
70397031 const displayName = chatDetails.characterName;
70407032 const avatarImg = chatDetails.avatarImgURL;
70417033
70427034 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
71197035
71207036 const debouncedDisplay = debounce((searchQuery) => {
71217037 displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group);
71227038 });
71237039
71247040 // Define the search input listener
@@ -7136,6 +7052,53 @@ export async function displayPastChats() {
71367052 }, 200);
71377053}
71387054
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+
71397102export function selectRightMenuWithAnimation(selectedMenuId) {
71407103 const displayModes = {
71417104 'rm_group_chats_block': 'flex',
@@ -10418,8 +10381,6 @@ jQuery(async function () {
1041810381 easing: animation_easing,
1041910382 });
1042010383 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');
1042310384 });
1042410385
1042510386 if (navigator.clipboard === undefined) {
@@ -10819,7 +10780,6 @@ jQuery(async function () {
1081910780 var formData = new FormData($('#form_import_chat').get(0));
1082010781 formData.append('user_name', name1);
1082110782 $('#select_chat_div').html('');
10822- $('#load_select_chat_div').css('display', 'block');
1082310783
1082410784 if (selected_group) {
1082510785 await importGroupChat(formData);
public/scripts/bookmarks.js+0 -1
@@ -636,7 +636,6 @@ export function initBookmarks() {
636636 }
637637
638638 $('#shadow_select_chat_popup').css('display', 'none');
639- $('#load_select_chat_div').css('display', 'block');
640639 });
641640
642641 $(document).on('click', '.mes_create_bookmark', async function () {
public/style.css+10 -15
@@ -4244,8 +4244,8 @@ h5 {
42444244}
42454245
42464246#select_chat_popup {
42474247 display: gridflex;
42484248 grid-templateflex-rowsdirection: auto autocolumn;
42494249 max-width: var(--sheldWidth);
42504250 height: min-content;
42514251 max-height: calc(100vh - var(--topBarBlockSize));
@@ -4265,7 +4265,7 @@ h5 {
42654265 padding: 10px;
42664266 background-color: var(--SmartThemeBlurTintColor);
42674267 border-radius: 10px;
42684268 overflow-y: autohidden;
42694269 border: 1px solid var(--SmartThemeBorderColor);
42704270}
42714271
@@ -4273,20 +4273,10 @@ h5 {
42734273 cursor: pointer;
42744274}
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-
42874276#select_chat_div {
42884277 padding: 0;
42894278 height: min-content100%;
4279+ overflow-y: auto;
42904280}
42914281
42924282#select_chat_div hr {
@@ -4333,6 +4323,11 @@ h5 {
43334323
43344324.select_chat_block_mes {
43354325 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;
43364331}
43374332
43384333.PastChat_cross {
src/endpoints/chats.js+147 -0
@@ -52,6 +52,37 @@ function getBackupFunction(handle) {
5252 return backupFunctions.get(handle);
5353}
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+ */
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+
5586process.on('exit', () => {
5687 for (const func of backupFunctions.values()) {
5788 func.flush();
@@ -516,3 +547,119 @@ router.post('/group/save', jsonParser, (request, response) => {
516547 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData);
517548 return response.send({ ok: true });
518549});
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+});