Merge pull request #3990 from SillyTavern/welcome-screen New welcome screen + quick chat functionality

5ed6d1cd9b78d22f730ec84880b75b629a2e71af

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

Signed
13 files changed, +966 -75Showing whitespace changes
public/css/welcome.css+209 -0
@@ -0,0 +1,209 @@
1#chat .mes[type="assistant_message"] .mes_button {
2 display: none;
3}
4
5.welcomePanel {
6 display: flex;
7 flex-direction: column;
8 gap: 5px;
9 padding: 10px;
10 width: 100%;
11}
12
13.welcomePanel:has(.showMoreChats) {
14 padding-bottom: 5px;
15}
16
17.welcomePanel.recentHidden .welcomeRecent,
18.welcomePanel.recentHidden .recentChatsTitle,
19.welcomePanel.recentHidden .hideRecentChats,
20.welcomePanel:not(.recentHidden) .showRecentChats {
21 display: none;
22}
23
24body.bubblechat .welcomePanel {
25 border-radius: 10px;
26 background-color: var(--SmartThemeBotMesBlurTintColor);
27 border: 1px solid var(--SmartThemeBorderColor);
28 margin-bottom: 5px;
29}
30
31.welcomePanel .welcomeHeader {
32 display: flex;
33 flex-direction: row;
34 align-items: center;
35 justify-content: flex-end;
36}
37
38.welcomePanel .recentChatsTitle {
39 flex-grow: 1;
40 font-size: calc(var(--mainFontSize) * 1.15);
41 font-weight: 600;
42}
43
44.welcomePanel .welcomeHeaderTitle {
45 margin: 0;
46 flex-grow: 1;
47 display: flex;
48 flex-direction: row;
49 align-items: center;
50 gap: 10px;
51}
52
53.welcomePanel .welcomeHeaderVersionDisplay {
54 font-size: calc(var(--mainFontSize) * 1.3);
55 font-weight: 600;
56 flex-grow: 1;
57}
58
59.welcomePanel .welcomeHeaderLogo {
60 width: 30px;
61 height: 30px;
62}
63
64.welcomePanel .welcomeShortcuts {
65 display: flex;
66 flex-direction: row;
67 flex-wrap: wrap;
68 align-items: center;
69 justify-content: center;
70 gap: 5px;
71}
72
73.welcomePanel .welcomeShortcuts .welcomeShortcutsSeparator {
74 margin: 0 2px;
75 color: var(--SmartThemeBorderColor);
76 font-size: calc(var(--mainFontSize) * 1.1);
77}
78
79.welcomeRecent .recentChatList {
80 display: flex;
81 flex-direction: column;
82 width: 100%;
83 gap: 2px;
84}
85
86.welcomeRecent .welcomePanelLoader {
87 display: flex;
88 justify-content: center;
89 align-items: center;
90 flex: 1;
91 width: 100%;
92 height: 100%;
93 position: absolute;
94}
95
96.welcomePanel .recentChatList .noRecentChat {
97 display: flex;
98 flex-direction: row;
99 justify-content: center;
100 align-items: baseline;
101 gap: 5px;
102 padding: 10px;
103}
104
105.welcomeRecent .recentChatList .recentChat {
106 display: flex;
107 flex-direction: row;
108 align-items: center;
109 padding: 5px 10px;
110 border-radius: 10px;
111 cursor: pointer;
112 gap: 10px;
113 border: 1px solid var(--SmartThemeBorderColor);
114}
115
116.welcomeRecent .recentChatList .recentChat .avatar {
117 flex: 0;
118 align-self: center;
119}
120
121.welcomeRecent .recentChatList .recentChat:hover {
122 background-color: var(--white30a);
123}
124
125.welcomeRecent .recentChatList .recentChat .recentChatInfo {
126 display: flex;
127 flex-direction: column;
128 flex-wrap: nowrap;
129 flex-grow: 1;
130 overflow: hidden;
131 justify-content: center;
132 align-self: flex-start;
133}
134
135.welcomeRecent .recentChatList .recentChat .chatNameContainer {
136 display: flex;
137 flex-direction: row;
138 justify-content: space-between;
139 align-items: baseline;
140 font-size: calc(var(--mainFontSize) * 1);
141}
142
143.welcomeRecent .recentChatList .recentChat .chatNameContainer .chatName {
144 white-space: nowrap;
145 text-overflow: ellipsis;
146 overflow: hidden;
147}
148
149.welcomeRecent .recentChatList .recentChat .chatMessageContainer {
150 display: flex;
151 flex-direction: row;
152 align-items: center;
153 justify-content: space-between;
154 gap: 5px;
155 font-size: calc(var(--mainFontSize) * 0.85);
156}
157
158.welcomeRecent .recentChatList .recentChat .chatMessageContainer .chatMessage {
159 display: -webkit-box;
160 -webkit-box-orient: vertical;
161 -webkit-line-clamp: 2;
162 line-clamp: 2;
163 overflow: hidden;
164}
165
166body.big-avatars .welcomeRecent .recentChatList .recentChat .chatMessageContainer .chatMessage {
167 -webkit-line-clamp: 4;
168 line-clamp: 4;
169}
170
171.welcomeRecent .recentChatList .recentChat .chatStats {
172 display: flex;
173 flex-direction: row;
174 justify-content: flex-end;
175 align-items: baseline;
176 align-self: flex-start;
177 gap: 5px;
178}
179
180.welcomeRecent .recentChatList .recentChat .chatStats .counterBlock {
181 display: flex;
182 flex-direction: row;
183 align-items: baseline;
184 gap: 5px;
185}
186
187.welcomeRecent .recentChatList .recentChat .chatStats .counterBlock::after {
188 content: "|";
189 color: var(--SmartThemeBorderColor);
190 font-size: calc(var(--mainFontSize) * 0.95);
191}
192
193.welcomeRecent .recentChatList .recentChat.hidden {
194 display: none;
195}
196
197.welcomeRecent .recentChatList .showMoreChats {
198 align-self: center;
199}
200
201.welcomeRecent .recentChatList .showMoreChats.rotated {
202 transform: rotate(180deg);
203}
204
205@media screen and (max-width: 1000px) {
206 .welcomePanel .welcomeShortcuts a span {
207 display: none;
208 }
209}
public/index.html+6 -0
@@ -5362,6 +5362,9 @@
5362 <option id="import_tags" data-i18n="Import Tags">5362 <option id="import_tags" data-i18n="Import Tags">
5363 Import Tags5363 Import Tags
5364 </option>5364 </option>
5365 <option id="set_as_assistant" data-i18n="Set / Unset as Welcome Page Assistant">
5366 Set / Unset as Welcome Page Assistant
5367 </option>
5365 <!--<option id="dupe_button">5368 <!--<option id="dupe_button">
5366 Duplicate5369 Duplicate
5367 </option>5370 </option>
@@ -6383,6 +6386,9 @@
6383 <div class="wide100p character_name_block">6386 <div class="wide100p character_name_block">
6384 <span class="ch_name"></span>6387 <span class="ch_name"></span>
6385 <small class="ch_additional_info ch_add_placeholder">+++</small>6388 <small class="ch_additional_info ch_add_placeholder">+++</small>
6389 <small class="ch_assistant" title="This character will be used as a welcome page assistant." data-i18n="[title]This character will be used as a welcome page assistant.">
6390 <i class="fa-solid fa-sm fa-user-graduate"></i>
6391 </small>
6386 <small class="ch_additional_info character_version"></small>6392 <small class="ch_additional_info character_version"></small>
6387 <small class="ch_additional_info ch_avatar_url"></small>6393 <small class="ch_additional_info ch_avatar_url"></small>
6388 </div>6394 </div>
public/script.js+49 -12
@@ -282,6 +282,7 @@ import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js';
282import { getContext } from './scripts/st-context.js';282import { getContext } from './scripts/st-context.js';
283import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';283import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
284import { accountStorage } from './scripts/util/AccountStorage.js';284import { accountStorage } from './scripts/util/AccountStorage.js';
285import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
285286
286// API OBJECT FOR EXTERNAL WIRING287// API OBJECT FOR EXTERNAL WIRING
287globalThis.SillyTavern = {288globalThis.SillyTavern = {
@@ -529,6 +530,7 @@ export const event_types = {
529 CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',530 CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
530 TOOL_CALLS_PERFORMED: 'tool_calls_performed',531 TOOL_CALLS_PERFORMED: 'tool_calls_performed',
531 TOOL_CALLS_RENDERED: 'tool_calls_rendered',532 TOOL_CALLS_RENDERED: 'tool_calls_rendered',
533 CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown',
532};534};
533535
534export const eventSource = new EventEmitter([event_types.APP_READY]);536export const eventSource = new EventEmitter([event_types.APP_READY]);
@@ -563,7 +565,7 @@ let chat_create_date = '';
563let firstRun = false;565let firstRun = false;
564let settingsReady = false;566let settingsReady = false;
565let currentVersion = '0.0.0';567let currentVersion = '0.0.0';
566let displayVersion = 'SillyTavern';568export let displayVersion = 'SillyTavern';
567569
568let generatedPromptCache = '';570let generatedPromptCache = '';
569let generation_started = new Date();571let generation_started = new Date();
@@ -636,6 +638,7 @@ export const system_message_types = {
636 MACROS: 'macros',638 MACROS: 'macros',
637 WELCOME_PROMPT: 'welcome_prompt',639 WELCOME_PROMPT: 'welcome_prompt',
638 ASSISTANT_NOTE: 'assistant_note',640 ASSISTANT_NOTE: 'assistant_note',
641 ASSISTANT_MESSAGE: 'assistant_message',
639};642};
640643
641/**644/**
@@ -737,6 +740,7 @@ async function getSystemMessages() {
737 force_avatar: system_avatar,740 force_avatar: system_avatar,
738 is_user: false,741 is_user: false,
739 is_system: true,742 is_system: true,
743 uses_system_ui: true,
740 mes: await renderTemplateAsync('welcomePrompt'),744 mes: await renderTemplateAsync('welcomePrompt'),
741 extra: {745 extra: {
742 isSmallSys: true,746 isSmallSys: true,
@@ -983,8 +987,6 @@ async function firstLoadInit() {
983 ToolManager.initToolSlashCommands();987 ToolManager.initToolSlashCommands();
984 await initPresetManager();988 await initPresetManager();
985 await getSystemMessages();989 await getSystemMessages();
986 sendSystemMessage(system_message_types.WELCOME);
987 sendSystemMessage(system_message_types.WELCOME_PROMPT);
988 await getSettings();990 await getSettings();
989 initKeyboard();991 initKeyboard();
990 initDynamicStyles();992 initDynamicStyles();
@@ -1009,6 +1011,7 @@ async function firstLoadInit() {
1009 initSettingsSearch();1011 initSettingsSearch();
1010 initBulkEdit();1012 initBulkEdit();
1011 initReasoning();1013 initReasoning();
1014 initWelcomeScreen();
1012 await initScrapers();1015 await initScrapers();
1013 initCustomSelectedSamplers();1016 initCustomSelectedSamplers();
1014 addDebugFunctions();1017 addDebugFunctions();
@@ -1472,6 +1475,11 @@ function getCharacterBlock(item, id) {
1472 template.toggleClass('is_fav', item.fav || item.fav == 'true');1475 template.toggleClass('is_fav', item.fav || item.fav == 'true');
1473 template.find('.ch_fav').val(item.fav);1476 template.find('.ch_fav').val(item.fav);
14741477
1478 const isAssistant = item.avatar === getPermanentAssistantAvatar();
1479 if (!isAssistant) {
1480 template.find('.ch_assistant').remove();
1481 }
1482
1475 const description = item.data?.creator_notes || '';1483 const description = item.data?.creator_notes || '';
1476 if (description) {1484 if (description) {
1477 template.find('.ch_description').text(description);1485 template.find('.ch_description').text(description);
@@ -2040,7 +2048,7 @@ export async function sendTextareaMessage() {
2040 }2048 }
20412049
2042 if (textareaText && !selected_group && this_chid === undefined && name2 !== neutralCharacterName) {2050 if (textareaText && !selected_group && this_chid === undefined && name2 !== neutralCharacterName) {
2043 await newAssistantChat();2051 await newAssistantChat({ temporary: false });
2044 }2052 }
20452053
2046 Generate(generateType);2054 Generate(generateType);
@@ -2291,6 +2299,7 @@ function getMessageFromTemplate({
2291 timestamp,2299 timestamp,
2292 tokenCount,2300 tokenCount,
2293 extra,2301 extra,
2302 type,
2294}) {2303}) {
2295 const mes = messageTemplate.clone();2304 const mes = messageTemplate.clone();
2296 mes.attr({2305 mes.attr({
@@ -2302,6 +2311,7 @@ function getMessageFromTemplate({
2302 'bookmark_link': bookmarkLink,2311 'bookmark_link': bookmarkLink,
2303 'force_avatar': !!forceAvatar,2312 'force_avatar': !!forceAvatar,
2304 'timestamp': timestamp,2313 'timestamp': timestamp,
2314 ...(type ? { type } : {}),
2305 });2315 });
2306 mes.find('.avatar img').attr('src', avatarImg);2316 mes.find('.avatar img').attr('src', avatarImg);
2307 mes.find('.ch_name .name_text').text(characterName);2317 mes.find('.ch_name .name_text').text(characterName);
@@ -2513,6 +2523,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
2513 timestamp: timestamp,2523 timestamp: timestamp,
2514 extra: mes.extra,2524 extra: mes.extra,
2515 tokenCount: mes.extra?.token_count ?? 0,2525 tokenCount: mes.extra?.token_count ?? 0,
2526 type: mes.extra?.type ?? '',
2516 ...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token),2527 ...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token),
2517 };2528 };
25182529
@@ -2882,7 +2893,14 @@ export async function processCommands(message) {
2882 return true;2893 return true;
2883}2894}
28842895
2885export function sendSystemMessage(type, text, extra = {}) {2896/**
2897 * Gets a system message by type.
2898 * @param {string} type Type of system message
2899 * @param {string} [text] Text to be sent
2900 * @param {object} [extra] Additional data to be added to the message
2901 * @returns {object} System message object
2902 */
2903export function getSystemMessageByType(type, text, extra = {}) {
2886 const systemMessage = system_messages[type];2904 const systemMessage = system_messages[type];
28872905
2888 if (!systemMessage) {2906 if (!systemMessage) {
@@ -2905,7 +2923,17 @@ export function sendSystemMessage(type, text, extra = {}) {
29052923
2906 newMessage.extra = Object.assign(newMessage.extra, extra);2924 newMessage.extra = Object.assign(newMessage.extra, extra);
2907 newMessage.extra.type = type;2925 newMessage.extra.type = type;
2926 return newMessage;
2927}
29082928
2929/**
2930 * Sends a system message to the chat.
2931 * @param {string} type Type of system message
2932 * @param {string} [text] Text to be sent
2933 * @param {object} [extra] Additional data to be added to the message
2934 */
2935export function sendSystemMessage(type, text, extra = {}) {
2936 const newMessage = getSystemMessageByType(type, text, extra);
2909 chat.push(newMessage);2937 chat.push(newMessage);
2910 addOneMessage(newMessage);2938 addOneMessage(newMessage);
2911 is_send_press = false;2939 is_send_press = false;
@@ -10112,8 +10140,17 @@ async function removeCharacterFromUI() {
10112 saveSettingsDebounced();10140 saveSettingsDebounced();
10113}10141}
1011410142
10115async function newAssistantChat() {10143/**
10144 * Creates a new assistant chat.
10145 * @param {object} params - Parameters for the new assistant chat
10146 * @param {boolean} [params.temporary=false] I need a temporary secretary
10147 * @returns {Promise<void>} - A promise that resolves when the new assistant chat is created
10148 */
10149export async function newAssistantChat({ temporary = false } = {}) {
10116 await clearChat();10150 await clearChat();
10151 if (!temporary) {
10152 return openPermanentAssistantChat();
10153 }
10117 chat.splice(0, chat.length);10154 chat.splice(0, chat.length);
10118 chat_metadata = {};10155 chat_metadata = {};
10119 setCharacterName(neutralCharacterName);10156 setCharacterName(neutralCharacterName);
@@ -10426,7 +10463,7 @@ jQuery(async function () {
10426 if (chatId) {10463 if (chatId) {
10427 return reject('Not in a temporary chat');10464 return reject('Not in a temporary chat');
10428 }10465 }
10429 await newAssistantChat();10466 await newAssistantChat({ temporary: true });
10430 return resolve('');10467 return resolve('');
10431 };10468 };
10432 eventSource.once(event_types.CHAT_CHANGED, eventCallback);10469 eventSource.once(event_types.CHAT_CHANGED, eventCallback);
@@ -11093,6 +11130,9 @@ jQuery(async function () {
11093 });11130 });
1109411131
11095 if (id == 'option_select_chat') {11132 if (id == 'option_select_chat') {
11133 if (this_chid === undefined && !is_send_press) {
11134 await openPermanentAssistantCard();
11135 }
11096 if ((selected_group && !is_group_generating) || (this_chid !== undefined && !is_send_press) || fromSlashCommand) {11136 if ((selected_group && !is_group_generating) || (this_chid !== undefined && !is_send_press) || fromSlashCommand) {
11097 await displayPastChats();11137 await displayPastChats();
11098 //this is just to avoid the shadow for past chat view when using /delchat11138 //this is just to avoid the shadow for past chat view when using /delchat
@@ -11123,7 +11163,7 @@ jQuery(async function () {
11123 await doNewChat({ deleteCurrentChat: deleteCurrentChat });11163 await doNewChat({ deleteCurrentChat: deleteCurrentChat });
11124 }11164 }
11125 if (!selected_group && this_chid === undefined && !is_send_press) {11165 if (!selected_group && this_chid === undefined && !is_send_press) {
11126 await newAssistantChat();11166 await newAssistantChat({ temporary: true });
11127 }11167 }
11128 }11168 }
1112911169
@@ -11176,9 +11216,6 @@ jQuery(async function () {
11176 selected_button = 'characters';11216 selected_button = 'characters';
11177 $('#rm_button_selected_ch').children('h2').text('');11217 $('#rm_button_selected_ch').children('h2').text('');
11178 select_rm_characters();11218 select_rm_characters();
11179 sendSystemMessage(system_message_types.WELCOME);
11180 sendSystemMessage(system_message_types.WELCOME_PROMPT);
11181 await getClientVersion();
11182 await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());11219 await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
11183 } else {11220 } else {
11184 toastr.info('Please stop the message generation first.');11221 toastr.info('Please stop the message generation first.');
@@ -12108,7 +12145,7 @@ jQuery(async function () {
12108 );12145 );
12109 break;*/12146 break;*/
12110 default:12147 default:
12111 await eventSource.emit('charManagementDropdown', target);12148 await eventSource.emit(event_types.CHARACTER_MANAGEMENT_DROPDOWN, target);
12112 }12149 }
12113 $('#char-management-dropdown').prop('selectedIndex', 0);12150 $('#char-management-dropdown').prop('selectedIndex', 0);
12114 });12151 });
public/scripts/chats.js+33 -0
@@ -23,6 +23,9 @@ import {
23 neutralCharacterName,23 neutralCharacterName,
24 updateChatMetadata,24 updateChatMetadata,
25 system_message_types,25 system_message_types,
26 getSystemMessageByType,
27 printMessages,
28 clearChat,
26} from '../script.js';29} from '../script.js';
27import { selected_group } from './group-chats.js';30import { selected_group } from './group-chats.js';
28import { power_user } from './power-user.js';31import { power_user } from './power-user.js';
@@ -37,6 +40,7 @@ import {
37 saveBase64AsFile,40 saveBase64AsFile,
38 extractTextFromOffice,41 extractTextFromOffice,
39 download,42 download,
43 getFileText,
40} from './utils.js';44} from './utils.js';
41import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';45import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
42import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';46import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -1497,6 +1501,35 @@ jQuery(function () {
1497 download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json');1501 download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json');
1498 });1502 });
14991503
1504 $(document).on('click', '.assistant_note_import', async function () {
1505 const importFile = async () => {
1506 const file = fileInput.files[0];
1507 if (!file) {
1508 return;
1509 }
1510
1511 try {
1512 const text = await getFileText(file);
1513 const lines = text.split('\n').filter(line => line.trim() !== '');
1514 const messages = lines.map(line => JSON.parse(line));
1515 const metadata = messages.shift()?.chat_metadata || {};
1516 messages.unshift(getSystemMessageByType(system_message_types.ASSISTANT_NOTE));
1517 await clearChat();
1518 chat.splice(0, chat.length, ...messages);
1519 updateChatMetadata(metadata, true);
1520 await printMessages();
1521 } catch (error) {
1522 console.error('Error importing assistant chat:', error);
1523 toastr.error(t`It's either corrupted or not a valid JSONL file.`, t`Failed to import chat`);
1524 }
1525 };
1526 const fileInput = document.createElement('input');
1527 fileInput.type = 'file';
1528 fileInput.accept = '.jsonl';
1529 fileInput.addEventListener('change', importFile);
1530 fileInput.click();
1531 });
1532
1500 // Do not change. #attachFile is added by extension.1533 // Do not change. #attachFile is added by extension.
1501 $(document).on('click', '#attachFile', function () {1534 $(document).on('click', '#attachFile', function () {
1502 $('#file_form_input').trigger('click');1535 $('#file_form_input').trigger('click');
public/scripts/extensions/gallery/index.js+1 -1
@@ -710,7 +710,7 @@ async function listGalleryCommand(args) {
710 delete context.extensionSettings.gallery.folders[avatar];710 delete context.extensionSettings.gallery.folders[avatar];
711 context.saveSettingsDebounced();711 context.saveSettingsDebounced();
712 });712 });
713 eventSource.on('charManagementDropdown', (selectedOptionId) => {713 eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (selectedOptionId) => {
714 if (selectedOptionId === 'show_char_gallery') {714 if (selectedOptionId === 'show_char_gallery') {
715 showCharGallery();715 showCharGallery();
716 }716 }
public/scripts/personas.js+1 -1
@@ -1970,7 +1970,7 @@ export async function initPersonas() {
19701970
1971 $('#char_connections_button').on('click', showCharConnections);1971 $('#char_connections_button').on('click', showCharConnections);
19721972
1973 eventSource.on('charManagementDropdown', (target) => {1973 eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => {
1974 if (target === 'convert_to_persona') {1974 if (target === 'convert_to_persona') {
1975 convertCharacterToPersona();1975 convertCharacterToPersona();
1976 }1976 }
public/scripts/templates/assistantNote.html+8 -4
@@ -1,9 +1,13 @@
1<div data-type="assistant_note">1<div data-type="assistant_note">
2 <div>2 <div class="assistant_note_title">
3 <b data-i18n="Note:">Note:</b> <span data-i18n="this chat is temporary and will be deleted as soon as you leave it.">this chat is temporary and will be deleted as soon as you leave it.</span>3 <b data-i18n="Note:">Note:</b> <span data-i18n="this chat is temporary and will be deleted as soon as you leave it.">this chat is temporary and will be deleted as soon as you leave it.</span>
4 <span data-i18n="Click the button to save it as a file.">Click the button to save it as a file.</span>
5 </div>4 </div>
6 <div class="assistant_note_export menu_button menu_button_icon" data-i18n="[title]Export as JSONL" title="Export as JSONL">5 <button class="assistant_note_import menu_button menu_button_icon margin0" data-i18n="[title]Import from JSONL" title="Import from JSONL">
6 <i class="fa-solid fa-file-import"></i>
7 <span data-i18n="Load">Load</span>
8 </button>
9 <button class="assistant_note_export menu_button menu_button_icon margin0" data-i18n="[title]Export as JSONL" title="Export as JSONL">
7 <i class="fa-solid fa-file-export"></i>10 <i class="fa-solid fa-file-export"></i>
8 </div>11 <span data-i18n="Save">Save</span>
12 </button>
9</div>13</div>
public/scripts/templates/welcomePanel.html+82 -0
@@ -0,0 +1,82 @@
1<div class="welcomePanel">
2 <div class="welcomeHeaderTitle">
3 <img src="img/logo.png" alt="SillyTavern Logo" class="welcomeHeaderLogo">
4 <span class="welcomeHeaderVersionDisplay">{{version}}</span>
5 <div class="mes_button showRecentChats" title="Show recent chats" data-i18n="[title]Show recent chats">
6 <i class="fa-solid fa-circle-chevron-down fa-fw fa-lg"></i>
7 </div>
8 <div class="mes_button hideRecentChats" title="Hide recent chats" data-i18n="[title]Hide recent chats">
9 <i class="fa-solid fa-circle-xmark fa-fw fa-lg"></i>
10 </div>
11 </div>
12 <div class="welcomeHeader">
13 <div class="recentChatsTitle" data-i18n="Recent Chats">
14 Recent Chats
15 </div>
16 <div class="welcomeShortcuts">
17 <a class="menu_button menu_button_icon" target="_blank" href="https://docs.sillytavern.app/">
18 <i class="fa-solid fa-question-circle"></i>
19 <span data-i18n="Docs">Docs</span>
20 </a>
21 <a class="menu_button menu_button_icon" target="_blank" href="https://github.com/SillyTavern/SillyTavern">
22 <i class="fa-brands fa-github"></i>
23 <span data-i18n="GitHub">GitHub</span>
24 </a>
25 <a class="menu_button menu_button_icon" target="_blank" href="https://discord.gg/sillytavern">
26 <i class="fa-brands fa-discord"></i>
27 <span data-i18n="Discord">Discord</span>
28 </a>
29 <span class="welcomeShortcutsSeparator">&vert;</span>
30 <button class="openTemporaryChat menu_button menu_button_icon">
31 <i class="fa-solid fa-comment-dots"></i>
32 <span data-i18n="Temporary Chat">Temporary Chat</span>
33 </button>
34 </div>
35 </div>
36 <div class="welcomeRecent">
37 <div class="recentChatList">
38 {{#if empty}}
39 <div class="noRecentChat">
40 <i class="fa-solid fa-comment-dots"></i>
41 <span data-i18n="No recent chats">No recent chats</span>
42 </div>
43 {{/if}}
44 {{#each chats}}
45 {{#with this}}
46 <div class="recentChat {{#if hidden}}hidden{{/if}} {{#if is_group}}group{{/if}}" data-file="{{chat_name}}" data-avatar="{{avatar}}" data-group="{{group}}">
47 <div class="avatar" title="[Character] {{char_name}}&#10;File: {{avatar}}">
48 <img src="{{char_thumbnail}}" alt="{{char_name}}">
49 </div>
50 <div class="recentChatInfo">
51 <div class="chatNameContainer">
52 <div class="chatName" title="{{file_name}}">
53 <strong class="characterName">{{char_name}}</strong>
54 <span>&ndash;</span>
55 <span>{{chat_name}}</span>
56 </div>
57 <small class="chatDate" title="{{date_long}}">{{date_short}}</small>
58 </div>
59 <div class="chatMessageContainer">
60 <div class="chatMessage" title="{{mes}}">
61 {{mes}}
62 </div>
63 <div class="chatStats">
64 <div class="counterBlock">
65 <i class="fa-solid fa-comment fa-xs"></i>
66 <small>{{chat_items}}</small>
67 </div>
68 <small class="fileSize">{{file_size}}</small>
69 </div>
70 </div>
71 </div>
72 </div>
73 {{/with}}
74 {{/each}}
75 {{#if more}}
76 <button class="menu_button menu_button_icon showMoreChats">
77 <small class="fa-solid fa-chevron-down fa-fw fa-1x"></small>
78 </button>
79 {{/if}}
80 </div>
81 </div>
82</div>
public/scripts/templates/welcomePrompt.html+14 -3
@@ -1,3 +1,14 @@
1<strong data-i18n="If you're connected to an API, try asking me something!">1<div class="flex-container">
2 If you're connected to an API, try asking me something!2 <button class="menu_button menu_button_icon drawer-opener inline-flex" data-target="sys-settings-button">
3</strong>3 <i class="fa-solid fa-plug"></i>
4 <span data-i18n="API Connections">API Connections</span>
5 </button>
6 <button class="menu_button menu_button_icon drawer-opener inline-flex" data-target="rightNavHolder">
7 <i class="fa-solid fa-address-card"></i>
8 <span data-i18n="Character Management">Character Management</span>
9 </button>
10 <button class="menu_button menu_button_icon drawer-opener inline-flex" data-target="extensions-settings-button">
11 <i class="fa-solid fa-cubes"></i>
12 <span data-i18n="Extensions">Extensions</span>
13 </button>
14</div>
public/scripts/welcome-screen.js+413 -0
@@ -0,0 +1,413 @@
1import {
2 addOneMessage,
3 characters,
4 chat,
5 displayVersion,
6 doNewChat,
7 event_types,
8 eventSource,
9 getCharacters,
10 getCurrentChatId,
11 getRequestHeaders,
12 getSystemMessageByType,
13 getThumbnailUrl,
14 is_send_press,
15 neutralCharacterName,
16 newAssistantChat,
17 openCharacterChat,
18 printCharactersDebounced,
19 selectCharacterById,
20 system_avatar,
21 system_message_types,
22 this_chid,
23} from '../script.js';
24import { getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js';
25import { t } from './i18n.js';
26import { renderTemplateAsync } from './templates.js';
27import { accountStorage } from './util/AccountStorage.js';
28import { sortMoments, timestampToMoment } from './utils.js';
29
30const assistantAvatarKey = 'assistant';
31const defaultAssistantAvatar = 'default_Assistant.png';
32
33const DEFAULT_DISPLAYED = 3;
34const MAX_DISPLAYED = 15;
35
36export function getPermanentAssistantAvatar() {
37 const assistantAvatar = accountStorage.getItem(assistantAvatarKey);
38 if (assistantAvatar === null) {
39 return defaultAssistantAvatar;
40 }
41
42 const character = characters.find(x => x.avatar === assistantAvatar);
43 if (character === undefined) {
44 accountStorage.removeItem(assistantAvatarKey);
45 return defaultAssistantAvatar;
46 }
47
48 return assistantAvatar;
49}
50
51export async function openWelcomeScreen() {
52 const currentChatId = getCurrentChatId();
53 if (currentChatId !== undefined || chat.length > 0) {
54 return;
55 }
56
57 await sendWelcomePanel();
58 sendAssistantMessage();
59 sendWelcomePrompt();
60}
61
62function sendAssistantMessage() {
63 const currentAssistantAvatar = getPermanentAssistantAvatar();
64 const character = characters.find(x => x.avatar === currentAssistantAvatar);
65 const name = character ? character.name : neutralCharacterName;
66 const avatar = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar;
67
68 const message = {
69 name: name,
70 force_avatar: avatar,
71 mes: t`If you're connected to an API, try asking me something!` + '\n***\n' + t`**Hint:** Set any character as your welcome page assistant from their "More..." menu.`,
72 is_system: false,
73 is_user: false,
74 extra: {
75 type: system_message_types.ASSISTANT_MESSAGE,
76 },
77 };
78
79 chat.push(message);
80 addOneMessage(message, { scroll: false });
81}
82
83function sendWelcomePrompt() {
84 const message = getSystemMessageByType(system_message_types.WELCOME_PROMPT);
85 chat.push(message);
86 addOneMessage(message, { scroll: false });
87}
88
89async function sendWelcomePanel() {
90 try {
91 const chatElement = document.getElementById('chat');
92 const sendTextArea = document.getElementById('send_textarea');
93 if (!chatElement) {
94 console.error('Chat element not found');
95 return;
96 }
97 const chats = await getRecentChats();
98 const templateData = {
99 chats,
100 empty: !chats.length,
101 version: displayVersion,
102 more: chats.some(chat => chat.hidden),
103 };
104 const template = await renderTemplateAsync('welcomePanel', templateData);
105 const fragment = document.createRange().createContextualFragment(template);
106 fragment.querySelectorAll('.welcomePanel').forEach((root) => {
107 const recentHiddenClass = 'recentHidden';
108 const recentHiddenKey = 'WelcomePage_RecentChatsHidden';
109 if (accountStorage.getItem(recentHiddenKey) === 'true') {
110 root.classList.add(recentHiddenClass);
111 }
112 root.querySelectorAll('.showRecentChats').forEach((button) => {
113 button.addEventListener('click', () => {
114 root.classList.remove(recentHiddenClass);
115 accountStorage.setItem(recentHiddenKey, 'false');
116 });
117 });
118 root.querySelectorAll('.hideRecentChats').forEach((button) => {
119 button.addEventListener('click', () => {
120 root.classList.add(recentHiddenClass);
121 accountStorage.setItem(recentHiddenKey, 'true');
122 });
123 });
124 });
125 fragment.querySelectorAll('.recentChat').forEach((item) => {
126 item.addEventListener('click', () => {
127 const avatarId = item.getAttribute('data-avatar');
128 const groupId = item.getAttribute('data-group');
129 const fileName = item.getAttribute('data-file');
130 if (avatarId && fileName) {
131 void openRecentCharacterChat(avatarId, fileName);
132 }
133 if (groupId && fileName) {
134 void openRecentGroupChat(groupId, fileName);
135 }
136 });
137 });
138 const hiddenChats = fragment.querySelectorAll('.recentChat.hidden');
139 fragment.querySelectorAll('button.showMoreChats').forEach((button) => {
140 const showRecentChatsTitle = t`Show more recent chats`;
141 const hideRecentChatsTitle = t`Show less recent chats`;
142
143 button.setAttribute('title', showRecentChatsTitle);
144 button.addEventListener('click', () => {
145 const rotate = button.classList.contains('rotated');
146 hiddenChats.forEach((chatItem) => {
147 chatItem.classList.toggle('hidden', rotate);
148 });
149 button.classList.toggle('rotated', !rotate);
150 button.setAttribute('title', rotate ? showRecentChatsTitle : hideRecentChatsTitle);
151 });
152 });
153 fragment.querySelectorAll('button.openTemporaryChat').forEach((button) => {
154 button.addEventListener('click', async () => {
155 await newAssistantChat({ temporary: true });
156 if (sendTextArea instanceof HTMLTextAreaElement) {
157 sendTextArea.focus();
158 }
159 });
160 });
161 fragment.querySelectorAll('.recentChat.group').forEach((groupChat) => {
162 const groupId = groupChat.getAttribute('data-group');
163 const group = groups.find(x => x.id === groupId);
164 if (group) {
165 const avatar = groupChat.querySelector('.avatar');
166 if (!avatar) {
167 return;
168 }
169 const groupAvatar = getGroupAvatar(group);
170 $(avatar).replaceWith(groupAvatar);
171 }
172 });
173 chatElement.append(fragment.firstChild);
174 } catch (error) {
175 console.error('Welcome screen error:', error);
176 }
177}
178
179/**
180 * Opens a recent character chat.
181 * @param {string} avatarId Avatar file name
182 * @param {string} fileName Chat file name
183 */
184async function openRecentCharacterChat(avatarId, fileName) {
185 const characterId = characters.findIndex(x => x.avatar === avatarId);
186 if (characterId === -1) {
187 console.error(`Character not found for avatar ID: ${avatarId}`);
188 return;
189 }
190
191 try {
192 await selectCharacterById(characterId);
193 const currentChatId = getCurrentChatId();
194 if (currentChatId === fileName) {
195 console.debug(`Chat ${fileName} is already open.`);
196 return;
197 }
198 await openCharacterChat(fileName);
199 } catch (error) {
200 console.error('Error opening recent chat:', error);
201 toastr.error(t`Failed to open recent chat. See console for details.`);
202 }
203}
204
205/**
206 * Opens a recent group chat.
207 * @param {string} groupId Group ID
208 * @param {string} fileName Chat file name
209 */
210async function openRecentGroupChat(groupId, fileName) {
211 const group = groups.find(x => x.id === groupId);
212 if (!group) {
213 console.error(`Group not found for ID: ${groupId}`);
214 return;
215 }
216
217 try {
218 await openGroupById(groupId);
219 const currentChatId = getCurrentChatId();
220 if (currentChatId === fileName) {
221 console.debug(`Chat ${fileName} is already open.`);
222 return;
223 }
224 await openGroupChat(groupId, fileName);
225 } catch (error) {
226 console.error('Error opening recent group chat:', error);
227 toastr.error(t`Failed to open recent group chat. See console for details.`);
228 }
229}
230
231/**
232 * Gets the list of recent chats from the server.
233 * @returns {Promise<RecentChat[]>} List of recent chats
234 *
235 * @typedef {object} RecentChat
236 * @property {string} file_name Name of the chat file
237 * @property {string} chat_name Name of the chat (without extension)
238 * @property {string} file_size Size of the chat file
239 * @property {number} chat_items Number of items in the chat
240 * @property {string} mes Last message content
241 * @property {string} last_mes Timestamp of the last message
242 * @property {string} avatar Avatar URL
243 * @property {string} char_thumbnail Thumbnail URL
244 * @property {string} char_name Character or group name
245 * @property {string} date_short Date in short format
246 * @property {string} date_long Date in long format
247 * @property {string} group Group ID (if applicable)
248 * @property {boolean} is_group Indicates if the chat is a group chat
249 * @property {boolean} hidden Chat will be hidden by default
250 */
251async function getRecentChats() {
252 const response = await fetch('/api/chats/recent', {
253 method: 'POST',
254 headers: getRequestHeaders(),
255 body: JSON.stringify({ max: MAX_DISPLAYED }),
256 });
257
258 if (!response.ok) {
259 console.warn('Failed to fetch recent character chats');
260 return [];
261 }
262
263 /** @type {RecentChat[]} */
264 const data = await response.json();
265
266 data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)))
267 .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) }))
268 .filter(t => t.character || t.group)
269 .forEach(({ chat, character, group }, index) => {
270 const chatTimestamp = timestampToMoment(chat.last_mes);
271 chat.char_name = character?.name || group?.name || '';
272 chat.date_short = chatTimestamp.format('l');
273 chat.date_long = chatTimestamp.format('LL LT');
274 chat.chat_name = chat.file_name.replace('.jsonl', '');
275 chat.char_thumbnail = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar;
276 chat.is_group = !!group;
277 chat.hidden = index >= DEFAULT_DISPLAYED;
278 chat.avatar = chat.avatar || '';
279 chat.group = chat.group || '';
280 });
281
282 return data;
283}
284
285export async function openPermanentAssistantChat({ tryCreate = true, created = false } = {}) {
286 const avatar = getPermanentAssistantAvatar();
287 const characterId = characters.findIndex(x => x.avatar === avatar);
288 if (characterId === -1) {
289 if (!tryCreate) {
290 console.error(`Character not found for avatar ID: ${avatar}. Cannot create.`);
291 return;
292 }
293
294 try {
295 console.log(`Character not found for avatar ID: ${avatar}. Creating new assistant.`);
296 await createPermanentAssistant();
297 return openPermanentAssistantChat({ tryCreate: false, created: true });
298 }
299 catch (error) {
300 console.error('Error creating permanent assistant:', error);
301 toastr.error(t`Failed to create ${neutralCharacterName}. See console for details.`);
302 return;
303 }
304 }
305
306 try {
307 await selectCharacterById(characterId);
308 if (!created) {
309 await doNewChat({ deleteCurrentChat: false });
310 }
311 console.log(`Opened permanent assistant chat for ${neutralCharacterName}.`, getCurrentChatId());
312 } catch (error) {
313 console.error('Error opening permanent assistant chat:', error);
314 toastr.error(t`Failed to open permanent assistant chat. See console for details.`);
315 }
316}
317
318async function createPermanentAssistant() {
319 if (is_group_generating || is_send_press) {
320 throw new Error(t`Cannot create while generating.`);
321 }
322
323 const formData = new FormData();
324 formData.append('ch_name', neutralCharacterName);
325 formData.append('file_name', defaultAssistantAvatar.replace('.png', ''));
326 formData.append('creator_notes', t`Automatically created character. Feel free to edit.`);
327
328 try {
329 const avatarResponse = await fetch(system_avatar);
330 const avatarBlob = await avatarResponse.blob();
331 formData.append('avatar', avatarBlob, defaultAssistantAvatar);
332 } catch (error) {
333 console.warn('Error fetching system avatar. Fallback image will be used.', error);
334 }
335
336 const headers = getRequestHeaders();
337 delete headers['Content-Type'];
338
339 const fetchResult = await fetch('/api/characters/create', {
340 method: 'POST',
341 headers: headers,
342 body: formData,
343 cache: 'no-cache',
344 });
345
346 if (!fetchResult.ok) {
347 throw new Error(t`Creation request did not succeed.`);
348 }
349
350 await getCharacters();
351}
352
353export async function openPermanentAssistantCard() {
354 const avatar = getPermanentAssistantAvatar();
355 const characterId = characters.findIndex(x => x.avatar === avatar);
356 if (characterId === -1) {
357 toastr.info(t`Assistant not found. Try sending a chat message.`);
358 return;
359 }
360
361 await selectCharacterById(characterId);
362}
363
364/**
365 * Assigns a character as the assistant.
366 * @param {string?} characterId Character ID
367 */
368export function assignCharacterAsAssistant(characterId) {
369 if (characterId === undefined) {
370 return;
371 }
372 /** @type {import('./char-data.js').v1CharData} */
373 const character = characters[characterId];
374 if (!character) {
375 return;
376 }
377
378 const currentAssistantAvatar = getPermanentAssistantAvatar();
379 if (currentAssistantAvatar === character.avatar) {
380 if (character.avatar === defaultAssistantAvatar) {
381 toastr.info(t`${character.name} is a system assistant. Choose another character.`);
382 return;
383 }
384
385 toastr.info(t`${character.name} is no longer your assistant.`);
386 accountStorage.removeItem(assistantAvatarKey);
387 return;
388 }
389
390 accountStorage.setItem(assistantAvatarKey, character.avatar);
391 printCharactersDebounced();
392 toastr.success(t`Set ${character.name} as your assistant.`);
393}
394
395export function initWelcomeScreen() {
396 const events = [event_types.CHAT_CHANGED, event_types.APP_READY];
397 for (const event of events) {
398 eventSource.makeFirst(event, openWelcomeScreen);
399 }
400
401 eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => {
402 if (target !== 'set_as_assistant') {
403 return;
404 }
405 assignCharacterAsAssistant(this_chid);
406 });
407
408 eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => {
409 if (oldAvatar === getPermanentAssistantAvatar()) {
410 accountStorage.setItem(assistantAvatarKey, newAvatar);
411 }
412 });
413}
public/style.css+4 -3
@@ -10,6 +10,7 @@
10@import url(css/accounts.css);10@import url(css/accounts.css);
11@import url(css/tags.css);11@import url(css/tags.css);
12@import url(css/scrollable-button.css);12@import url(css/scrollable-button.css);
13@import url(css/welcome.css);
1314
14:root {15:root {
15 --doc-height: 100%;16 --doc-height: 100%;
@@ -6060,12 +6061,12 @@ body:not(.movingUI) .drawer-content.maximized {
6060 flex-wrap: nowrap;6061 flex-wrap: nowrap;
6061 justify-content: space-between;6062 justify-content: space-between;
6062 align-items: center;6063 align-items: center;
6063 gap: 10px;6064 gap: 5px;
6064 padding: 0 2px;
6065}6065}
60666066
6067.mes_text div[data-type="assistant_note"]:has(.assistant_note_export)>div:not(.assistant_note_export) {6067.mes_text div[data-type="assistant_note"]:has(.assistant_note_export) > div {
6068 flex: 1;6068 flex: 1;
6069 text-align: left;
6069}6070}
60706071
6071.oneline-dropdown label {6072.oneline-dropdown label {
src/endpoints/characters.js+7 -51
@@ -1,7 +1,6 @@
1import path from 'node:path';1import path from 'node:path';
2import fs from 'node:fs';2import fs from 'node:fs';
3import { promises as fsPromises } from 'node:fs';3import { promises as fsPromises } from 'node:fs';
4import readline from 'node:readline';
5import { Buffer } from 'node:buffer';4import { Buffer } from 'node:buffer';
65
7import express from 'express';6import express from 'express';
@@ -22,6 +21,7 @@ import { readWorldInfoFile } from './worldinfo.js';
22import { invalidateThumbnail } from './thumbnails.js';21import { invalidateThumbnail } from './thumbnails.js';
23import { importRisuSprites } from './sprites.js';22import { importRisuSprites } from './sprites.js';
24import { getUserDirectories } from '../users.js';23import { getUserDirectories } from '../users.js';
24import { getChatInfo } from './chats.js';
25const defaultAvatarPath = './public/img/ai4.png';25const defaultAvatarPath = './public/img/ai4.png';
2626
27// With 100 MB limit it would take roughly 3000 characters to reach this limit27// With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -577,10 +577,10 @@ function charaFormatData(data, directories) {
577 _.set(char, 'mes_example', data.mes_example || '');577 _.set(char, 'mes_example', data.mes_example || '');
578578
579 // Old ST extension fields (for backward compatibility, will be deprecated)579 // Old ST extension fields (for backward compatibility, will be deprecated)
580 _.set(char, 'creatorcomment', data.creator_notes);580 _.set(char, 'creatorcomment', data.creator_notes || '');
581 _.set(char, 'avatar', 'none');581 _.set(char, 'avatar', 'none');
582 _.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime());582 _.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime());
583 _.set(char, 'talkativeness', data.talkativeness);583 _.set(char, 'talkativeness', data.talkativeness || 0.5);
584 _.set(char, 'fav', data.fav == 'true');584 _.set(char, 'fav', data.fav == 'true');
585 _.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);585 _.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
586586
@@ -604,7 +604,7 @@ function charaFormatData(data, directories) {
604 _.set(char, 'data.alternate_greetings', getAlternateGreetings(data));604 _.set(char, 'data.alternate_greetings', getAlternateGreetings(data));
605605
606 // ST extension fields to V2 object606 // ST extension fields to V2 object
607 _.set(char, 'data.extensions.talkativeness', data.talkativeness);607 _.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5);
608 _.set(char, 'data.extensions.fav', data.fav == 'true');608 _.set(char, 'data.extensions.fav', data.fav == 'true');
609 _.set(char, 'data.extensions.world', data.world || '');609 _.set(char, 'data.extensions.world', data.world || '');
610610
@@ -943,7 +943,7 @@ router.post('/create', async function (request, response) {
943 request.body.ch_name = sanitize(request.body.ch_name);943 request.body.ch_name = sanitize(request.body.ch_name);
944944
945 const char = JSON.stringify(charaFormatData(request.body, request.user.directories));945 const char = JSON.stringify(charaFormatData(request.body, request.user.directories));
946 const internalName = getPngName(request.body.ch_name, request.user.directories);946 const internalName = request.body.file_name || getPngName(request.body.ch_name, request.user.directories);
947 const avatarName = `${internalName}.png`;947 const avatarName = `${internalName}.png`;
948 const chatsPath = path.join(request.user.directories.chats, internalName);948 const chatsPath = path.join(request.user.directories.chats, internalName);
949949
@@ -1228,7 +1228,6 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
1228 if (!request.body) return response.sendStatus(400);1228 if (!request.body) return response.sendStatus(400);
12291229
1230 const characterDirectory = (request.body.avatar_url).replace('.png', '');1230 const characterDirectory = (request.body.avatar_url).replace('.png', '');
1231
1232 const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);1231 const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
12331232
1234 if (!fs.existsSync(chatsDirectory)) {1233 if (!fs.existsSync(chatsDirectory)) {
@@ -1248,54 +1247,11 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
1248 }1247 }
12491248
1250 const jsonFilesPromise = jsonFiles.map((file) => {1249 const jsonFilesPromise = jsonFiles.map((file) => {
1251 return new Promise(async (res) => {
1252 const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);1250 const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1253 const fileStream = fs.createReadStream(pathToFile);1251 return getChatInfo(pathToFile);
1254 const stats = fs.statSync(pathToFile);
1255 const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`;
1256
1257 if (stats.size === 0) {
1258 console.warn(`Found an empty chat file: ${pathToFile}`);
1259 res({});
1260 return;
1261 }
1262
1263 const rl = readline.createInterface({
1264 input: fileStream,
1265 crlfDelay: Infinity,
1266 });
1267
1268 let lastLine;
1269 let itemCounter = 0;
1270 rl.on('line', (line) => {
1271 itemCounter++;
1272 lastLine = line;
1273 });
1274 rl.on('close', () => {
1275 rl.close();
1276
1277 if (lastLine) {
1278 const jsonData = tryParse(lastLine);
1279 if (jsonData && (jsonData.name || jsonData.character_name)) {
1280 const chatData = {};
1281
1282 chatData['file_name'] = file;
1283 chatData['file_size'] = fileSizeInKB;
1284 chatData['chat_items'] = itemCounter - 1;
1285 chatData['mes'] = jsonData['mes'] || '[The chat is empty]';
1286 chatData['last_mes'] = jsonData['send_date'] || Date.now();
1287
1288 res(chatData);
1289 } else {
1290 console.warn('Found an invalid or corrupted chat file:', pathToFile);
1291 res({});
1292 }
1293 }
1294 });
1295 });
1296 });1252 });
12971253
1298 const chatData = await Promise.all(jsonFilesPromise);1254 const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
1299 const validFiles = chatData.filter(i => i.file_name);1255 const validFiles = chatData.filter(i => i.file_name);
13001256
1301 return response.send(validFiles);1257 return response.send(validFiles);
src/endpoints/chats.js+139 -0
@@ -351,6 +351,69 @@ async function checkChatIntegrity(filePath, integritySlug) {
351 return chatIntegrity === integritySlug;351 return chatIntegrity === integritySlug;
352}352}
353353
354/**
355 * @typedef {Object} ChatInfo
356 * @property {string} [file_name] - The name of the chat file
357 * @property {string} [file_size] - The size of the chat file
358 * @property {number} [chat_items] - The number of chat items in the file
359 * @property {string} [mes] - The last message in the chat
360 * @property {number} [last_mes] - The timestamp of the last message
361 */
362
363/**
364 * Reads the information from a chat file.
365 * @param {string} pathToFile
366 * @param {object} additionalData
367 * @returns {Promise<ChatInfo>}
368 */
369export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false) {
370 return new Promise(async (res) => {
371 const fileStream = fs.createReadStream(pathToFile);
372 const stats = await fs.promises.stat(pathToFile);
373 const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`;
374
375 if (stats.size === 0) {
376 console.warn(`Found an empty chat file: ${pathToFile}`);
377 res({});
378 return;
379 }
380
381 const rl = readline.createInterface({
382 input: fileStream,
383 crlfDelay: Infinity,
384 });
385
386 let lastLine;
387 let itemCounter = 0;
388 rl.on('line', (line) => {
389 itemCounter++;
390 lastLine = line;
391 });
392 rl.on('close', () => {
393 rl.close();
394
395 if (lastLine) {
396 const jsonData = tryParse(lastLine);
397 if (jsonData && (jsonData.name || jsonData.character_name)) {
398 const chatData = {};
399
400 chatData['file_name'] = path.parse(pathToFile).base;
401 chatData['file_size'] = fileSizeInKB;
402 chatData['chat_items'] = isGroup ? itemCounter : (itemCounter - 1);
403 chatData['mes'] = jsonData['mes'] || '[The chat is empty]';
404 chatData['last_mes'] = jsonData['send_date'] || stats.mtimeMs;
405 Object.assign(chatData, additionalData);
406
407 res(chatData);
408 } else {
409 console.warn('Found an invalid or corrupted chat file:', pathToFile);
410 res({});
411 }
412 }
413 });
414 });
415}
416
354export const router = express.Router();417export const router = express.Router();
355418
356router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {419router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
@@ -809,3 +872,79 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
809 return response.status(500).json({ error: 'Search failed' });872 return response.status(500).json({ error: 'Search failed' });
810 }873 }
811});874});
875
876router.post('/recent', async function (request, response) {
877 try {
878 /** @type {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} */
879 const allChatFiles = [];
880
881 const getCharacterChatFiles = async () => {
882 const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true });
883 const pngFiles = pngDirents.filter(e => e.isFile() && path.extname(e.name) === '.png').map(e => e.name);
884
885 for (const pngFile of pngFiles) {
886 const chatsDirectory = pngFile.replace('.png', '');
887 const pathToChats = path.join(request.user.directories.chats, chatsDirectory);
888 if (!fs.existsSync(pathToChats)) {
889 continue;
890 }
891 const pathStats = await fs.promises.stat(pathToChats);
892 if (pathStats.isDirectory()) {
893 const chatFiles = await fs.promises.readdir(pathToChats);
894 const jsonlFiles = chatFiles.filter(file => path.extname(file) === '.jsonl');
895
896 for (const file of jsonlFiles) {
897 const filePath = path.join(pathToChats, file);
898 const stats = await fs.promises.stat(filePath);
899 allChatFiles.push({ pngFile, filePath, mtime: stats.mtimeMs });
900 }
901 }
902 }
903 };
904
905 const getGroupChatFiles = async () => {
906 const groupDirents = await fs.promises.readdir(request.user.directories.groups, { withFileTypes: true });
907 const groups = groupDirents.filter(e => e.isFile() && path.extname(e.name) === '.json').map(e => e.name);
908
909 for (const group of groups) {
910 try {
911 const groupPath = path.join(request.user.directories.groups, group);
912 const groupContents = await fs.promises.readFile(groupPath, 'utf8');
913 const groupData = JSON.parse(groupContents);
914
915 if (Array.isArray(groupData.chats)) {
916 for (const chat of groupData.chats) {
917 const filePath = path.join(request.user.directories.groupChats, `${chat}.jsonl`);
918 if (!fs.existsSync(filePath)) {
919 continue;
920 }
921 const stats = await fs.promises.stat(filePath);
922 allChatFiles.push({ groupId: groupData.id, filePath, mtime: stats.mtimeMs });
923 }
924 }
925 } catch (error) {
926 // Skip group files that can't be read or parsed
927 continue;
928 }
929 }
930 };
931
932 await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles()]);
933
934 const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER);
935 const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max);
936 const jsonFilesPromise = recentChats.map((file) => {
937 return file.groupId
938 ? getChatInfo(file.filePath, { group: file.groupId }, true)
939 : getChatInfo(file.filePath, { avatar: file.pngFile }, false);
940 });
941
942 const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
943 const validFiles = chatData.filter(i => i.file_name);
944
945 return response.send(validFiles);
946 } catch (error) {
947 console.error(error);
948 return response.sendStatus(500);
949 }
950});