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, +967 -76Ignore whitespace
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+
24+body.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+
166+body.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 @@
53625362 <option id="import_tags" data-i18n="Import Tags">
53635363 Import Tags
53645364 </option>
5365+ <option id="set_as_assistant" data-i18n="Set / Unset as Welcome Page Assistant">
5366+ Set / Unset as Welcome Page Assistant
5367+ </option>
53655368 <!--<option id="dupe_button">
53665369 Duplicate
53675370 </option>
@@ -6383,6 +6386,9 @@
63836386 <div class="wide100p character_name_block">
63846387 <span class="ch_name"></span>
63856388 <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>
63866392 <small class="ch_additional_info character_version"></small>
63876393 <small class="ch_additional_info ch_avatar_url"></small>
63886394 </div>
public/script.js+49 -12
@@ -282,6 +282,7 @@ import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js';
282282import { getContext } from './scripts/st-context.js';
283283import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
284284import { accountStorage } from './scripts/util/AccountStorage.js';
285+import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
285286
286287// API OBJECT FOR EXTERNAL WIRING
287288globalThis.SillyTavern = {
@@ -529,6 +530,7 @@ export const event_types = {
529530 CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
530531 TOOL_CALLS_PERFORMED: 'tool_calls_performed',
531532 TOOL_CALLS_RENDERED: 'tool_calls_rendered',
533+ CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown',
532534};
533535
534536export const eventSource = new EventEmitter([event_types.APP_READY]);
@@ -563,7 +565,7 @@ let chat_create_date = '';
563565let firstRun = false;
564566let settingsReady = false;
565567let currentVersion = '0.0.0';
566568export let displayVersion = 'SillyTavern';
567569
568570let generatedPromptCache = '';
569571let generation_started = new Date();
@@ -636,6 +638,7 @@ export const system_message_types = {
636638 MACROS: 'macros',
637639 WELCOME_PROMPT: 'welcome_prompt',
638640 ASSISTANT_NOTE: 'assistant_note',
641+ ASSISTANT_MESSAGE: 'assistant_message',
639642};
640643
641644/**
@@ -737,6 +740,7 @@ async function getSystemMessages() {
737740 force_avatar: system_avatar,
738741 is_user: false,
739742 is_system: true,
743+ uses_system_ui: true,
740744 mes: await renderTemplateAsync('welcomePrompt'),
741745 extra: {
742746 isSmallSys: true,
@@ -983,8 +987,6 @@ async function firstLoadInit() {
983987 ToolManager.initToolSlashCommands();
984988 await initPresetManager();
985989 await getSystemMessages();
986- sendSystemMessage(system_message_types.WELCOME);
987- sendSystemMessage(system_message_types.WELCOME_PROMPT);
988990 await getSettings();
989991 initKeyboard();
990992 initDynamicStyles();
@@ -1009,6 +1011,7 @@ async function firstLoadInit() {
10091011 initSettingsSearch();
10101012 initBulkEdit();
10111013 initReasoning();
1014+ initWelcomeScreen();
10121015 await initScrapers();
10131016 initCustomSelectedSamplers();
10141017 addDebugFunctions();
@@ -1472,6 +1475,11 @@ function getCharacterBlock(item, id) {
14721475 template.toggleClass('is_fav', item.fav || item.fav == 'true');
14731476 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+
14751483 const description = item.data?.creator_notes || '';
14761484 if (description) {
14771485 template.find('.ch_description').text(description);
@@ -2040,7 +2048,7 @@ export async function sendTextareaMessage() {
20402048 }
20412049
20422050 if (textareaText && !selected_group && this_chid === undefined && name2 !== neutralCharacterName) {
20432051 await newAssistantChat({ temporary: false });
20442052 }
20452053
20462054 Generate(generateType);
@@ -2291,6 +2299,7 @@ function getMessageFromTemplate({
22912299 timestamp,
22922300 tokenCount,
22932301 extra,
2302+ type,
22942303}) {
22952304 const mes = messageTemplate.clone();
22962305 mes.attr({
@@ -2302,6 +2311,7 @@ function getMessageFromTemplate({
23022311 'bookmark_link': bookmarkLink,
23032312 'force_avatar': !!forceAvatar,
23042313 'timestamp': timestamp,
2314+ ...(type ? { type } : {}),
23052315 });
23062316 mes.find('.avatar img').attr('src', avatarImg);
23072317 mes.find('.ch_name .name_text').text(characterName);
@@ -2513,6 +2523,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
25132523 timestamp: timestamp,
25142524 extra: mes.extra,
25152525 tokenCount: mes.extra?.token_count ?? 0,
2526+ type: mes.extra?.type ?? '',
25162527 ...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token),
25172528 };
25182529
@@ -2882,7 +2893,14 @@ export async function processCommands(message) {
28822893 return true;
28832894}
28842895
2885-export 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+ */
2903+export function getSystemMessageByType(type, text, extra = {}) {
28862904 const systemMessage = system_messages[type];
28872905
28882906 if (!systemMessage) {
@@ -2905,7 +2923,17 @@ export function sendSystemMessage(type, text, extra = {}) {
29052923
29062924 newMessage.extra = Object.assign(newMessage.extra, extra);
29072925 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+ */
2935+export function sendSystemMessage(type, text, extra = {}) {
2936+ const newMessage = getSystemMessageByType(type, text, extra);
29092937 chat.push(newMessage);
29102938 addOneMessage(newMessage);
29112939 is_send_press = false;
@@ -10112,8 +10140,17 @@ async function removeCharacterFromUI() {
1011210140 saveSettingsDebounced();
1011310141}
1011410142
10115-async 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+ */
10149+export async function newAssistantChat({ temporary = false } = {}) {
1011610150 await clearChat();
10151+ if (!temporary) {
10152+ return openPermanentAssistantChat();
10153+ }
1011710154 chat.splice(0, chat.length);
1011810155 chat_metadata = {};
1011910156 setCharacterName(neutralCharacterName);
@@ -10426,7 +10463,7 @@ jQuery(async function () {
1042610463 if (chatId) {
1042710464 return reject('Not in a temporary chat');
1042810465 }
1042910466 await newAssistantChat({ temporary: true });
1043010467 return resolve('');
1043110468 };
1043210469 eventSource.once(event_types.CHAT_CHANGED, eventCallback);
@@ -11093,6 +11130,9 @@ jQuery(async function () {
1109311130 });
1109411131
1109511132 if (id == 'option_select_chat') {
11133+ if (this_chid === undefined && !is_send_press) {
11134+ await openPermanentAssistantCard();
11135+ }
1109611136 if ((selected_group && !is_group_generating) || (this_chid !== undefined && !is_send_press) || fromSlashCommand) {
1109711137 await displayPastChats();
1109811138 //this is just to avoid the shadow for past chat view when using /delchat
@@ -11123,7 +11163,7 @@ jQuery(async function () {
1112311163 await doNewChat({ deleteCurrentChat: deleteCurrentChat });
1112411164 }
1112511165 if (!selected_group && this_chid === undefined && !is_send_press) {
1112611166 await newAssistantChat({ temporary: true });
1112711167 }
1112811168 }
1112911169
@@ -11176,9 +11216,6 @@ jQuery(async function () {
1117611216 selected_button = 'characters';
1117711217 $('#rm_button_selected_ch').children('h2').text('');
1117811218 select_rm_characters();
11179- sendSystemMessage(system_message_types.WELCOME);
11180- sendSystemMessage(system_message_types.WELCOME_PROMPT);
11181- await getClientVersion();
1118211219 await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
1118311220 } else {
1118411221 toastr.info('Please stop the message generation first.');
@@ -12108,7 +12145,7 @@ jQuery(async function () {
1210812145 );
1210912146 break;*/
1211012147 default:
1211112148 await eventSource.emit('charManagementDropdown'event_types.CHARACTER_MANAGEMENT_DROPDOWN, target);
1211212149 }
1211312150 $('#char-management-dropdown').prop('selectedIndex', 0);
1211412151 });
public/scripts/chats.js+33 -0
@@ -23,6 +23,9 @@ import {
2323 neutralCharacterName,
2424 updateChatMetadata,
2525 system_message_types,
26+ getSystemMessageByType,
27+ printMessages,
28+ clearChat,
2629} from '../script.js';
2730import { selected_group } from './group-chats.js';
2831import { power_user } from './power-user.js';
@@ -37,6 +40,7 @@ import {
3740 saveBase64AsFile,
3841 extractTextFromOffice,
3942 download,
43+ getFileText,
4044} from './utils.js';
4145import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
4246import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -1497,6 +1501,35 @@ jQuery(function () {
14971501 download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json');
14981502 });
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+
15001533 // Do not change. #attachFile is added by extension.
15011534 $(document).on('click', '#attachFile', function () {
15021535 $('#file_form_input').trigger('click');
public/scripts/extensions/gallery/index.js+1 -1
@@ -710,7 +710,7 @@ async function listGalleryCommand(args) {
710710 delete context.extensionSettings.gallery.folders[avatar];
711711 context.saveSettingsDebounced();
712712 });
713713 eventSource.on('charManagementDropdown'event_types.CHARACTER_MANAGEMENT_DROPDOWN, (selectedOptionId) => {
714714 if (selectedOptionId === 'show_char_gallery') {
715715 showCharGallery();
716716 }
public/scripts/personas.js+1 -1
@@ -1970,7 +1970,7 @@ export async function initPersonas() {
19701970
19711971 $('#char_connections_button').on('click', showCharConnections);
19721972
19731973 eventSource.on('charManagementDropdown'event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => {
19741974 if (target === 'convert_to_persona') {
19751975 convertCharacterToPersona();
19761976 }
public/scripts/templates/assistantNote.html+8 -4
@@ -1,9 +1,13 @@
11<div data-type="assistant_note">
22 <div class="assistant_note_title">
33 <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>
54 </div>
65 <divbutton class="assistant_note_exportassistant_note_import menu_button menu_button_icon margin0" data-i18n="[title]ExportImport asfrom JSONL" title="ExportImport asfrom 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">
710 <i class="fa-solid fa-file-export"></i>
8- </div>
11+ <span data-i18n="Save">Save</span>
12+ </button>
913</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 @@
1+import {
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';
24+import { getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js';
25+import { t } from './i18n.js';
26+import { renderTemplateAsync } from './templates.js';
27+import { accountStorage } from './util/AccountStorage.js';
28+import { sortMoments, timestampToMoment } from './utils.js';
29+
30+const assistantAvatarKey = 'assistant';
31+const defaultAssistantAvatar = 'default_Assistant.png';
32+
33+const DEFAULT_DISPLAYED = 3;
34+const MAX_DISPLAYED = 15;
35+
36+export 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+
51+export 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+
62+function 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+
83+function sendWelcomePrompt() {
84+ const message = getSystemMessageByType(system_message_types.WELCOME_PROMPT);
85+ chat.push(message);
86+ addOneMessage(message, { scroll: false });
87+}
88+
89+async 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+ */
184+async 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+ */
210+async 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+ */
251+async 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+
285+export 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+
318+async 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+
353+export 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+ */
368+export 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+
395+export 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 @@
1010@import url(css/accounts.css);
1111@import url(css/tags.css);
1212@import url(css/scrollable-button.css);
13+@import url(css/welcome.css);
1314
1415:root {
1516 --doc-height: 100%;
@@ -6060,12 +6061,12 @@ body:not(.movingUI) .drawer-content.maximized {
60606061 flex-wrap: nowrap;
60616062 justify-content: space-between;
60626063 align-items: center;
60636064 gap: 10px5px;
6064- padding: 0 2px;
60656065}
60666066
60676067.mes_text div[data-type="assistant_note"]:has(.assistant_note_export) > div:not(.assistant_note_export) {
60686068 flex: 1;
6069+ text-align: left;
60696070}
60706071
60716072.oneline-dropdown label {
src/endpoints/characters.js+8 -52
@@ -1,7 +1,6 @@
11import path from 'node:path';
22import fs from 'node:fs';
33import { promises as fsPromises } from 'node:fs';
4-import readline from 'node:readline';
54import { Buffer } from 'node:buffer';
65
76import express from 'express';
@@ -22,6 +21,7 @@ import { readWorldInfoFile } from './worldinfo.js';
2221import { invalidateThumbnail } from './thumbnails.js';
2322import { importRisuSprites } from './sprites.js';
2423import { getUserDirectories } from '../users.js';
24+import { getChatInfo } from './chats.js';
2525const defaultAvatarPath = './public/img/ai4.png';
2626
2727// With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -577,10 +577,10 @@ function charaFormatData(data, directories) {
577577 _.set(char, 'mes_example', data.mes_example || '');
578578
579579 // Old ST extension fields (for backward compatibility, will be deprecated)
580580 _.set(char, 'creatorcomment', data.creator_notes || '');
581581 _.set(char, 'avatar', 'none');
582582 _.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime());
583583 _.set(char, 'talkativeness', data.talkativeness || 0.5);
584584 _.set(char, 'fav', data.fav == 'true');
585585 _.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) {
604604 _.set(char, 'data.alternate_greetings', getAlternateGreetings(data));
605605
606606 // ST extension fields to V2 object
607607 _.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5);
608608 _.set(char, 'data.extensions.fav', data.fav == 'true');
609609 _.set(char, 'data.extensions.world', data.world || '');
610610
@@ -943,7 +943,7 @@ router.post('/create', async function (request, response) {
943943 request.body.ch_name = sanitize(request.body.ch_name);
944944
945945 const char = JSON.stringify(charaFormatData(request.body, request.user.directories));
946946 const internalName = request.body.file_name || getPngName(request.body.ch_name, request.user.directories);
947947 const avatarName = `${internalName}.png`;
948948 const chatsPath = path.join(request.user.directories.chats, internalName);
949949
@@ -1228,7 +1228,6 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
12281228 if (!request.body) return response.sendStatus(400);
12291229
12301230 const characterDirectory = (request.body.avatar_url).replace('.png', '');
1231-
12321231 const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
12331232
12341233 if (!fs.existsSync(chatsDirectory)) {
@@ -1248,54 +1247,11 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
12481247 }
12491248
12501249 const jsonFilesPromise = jsonFiles.map((file) => {
1251- return new Promise(async (res) => {
1250+ const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1252- const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1251+ return getChatInfo(pathToFile);
1253- const fileStream = fs.createReadStream(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- });
12961252 });
12971253
1298- const chatData = await Promise.all(jsonFilesPromise);
1254+ const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
12991255 const validFiles = chatData.filter(i => i.file_name);
13001256
13011257 return response.send(validFiles);
src/endpoints/chats.js+139 -0
@@ -351,6 +351,69 @@ async function checkChatIntegrity(filePath, integritySlug) {
351351 return chatIntegrity === integritySlug;
352352}
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+ */
369+export 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+
354417export const router = express.Router();
355418
356419router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
@@ -809,3 +872,79 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
809872 return response.status(500).json({ error: 'Search failed' });
810873 }
811874});
875+
876+router.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+});