Blame Raw
Cohee · 51ad27fb · · 949 lines (33.8 KB)
3 contributors
1import {
2 addOneMessage,
3 characters,
4 chat,
5 deleteCharacterChatByName,
6 displayVersion,
7 doNewChat,
8 event_types,
9 eventSource,
10 getCharacters,
11 getCurrentChatId,
12 getRequestHeaders,
13 getSystemMessageByType,
14 getThumbnailUrl,
15 is_send_press,
16 neutralCharacterName,
17 newAssistantChat,
18 openCharacterChat,
19 printCharactersDebounced,
20 renameGroupOrCharacterChat,
21 saveSettingsDebounced,
22 selectCharacterById,
23 setActiveCharacter,
24 setActiveGroup,
25 system_avatar,
26 system_message_types,
27 this_chid,
28 unshallowCharacter,
29 updateRemoteChatName,
30} from '../script.js';
31import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
32import { deleteGroupChatByName, getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js';
33import { t } from './i18n.js';
34import { callGenericPopup, POPUP_TYPE } from './popup.js';
35import { getMessageTimeStamp } from './RossAscends-mods.js';
36import { renderTemplateAsync } from './templates.js';
37import { accountStorage } from './util/AccountStorage.js';
38import { clamp, flashHighlight, isElementInViewport, sortMoments, timestampToMoment } from './utils.js';
39
40const assistantAvatarKey = 'assistant';
41const pinnedChatsKey = 'pinnedChats';
42const recentChatsSettingsKey = 'recentChatsSettings';
43const defaultAssistantAvatar = 'default_Assistant.png';
44
45const DEFAULT_MAX_DISPLAYED = 15;
46const DEFAULT_COLLAPSED_DISPLAYED = 3;
47
48/**
49 * Gets the current recent chats settings from account storage.
50 * @returns {{ maxDisplayed: number, collapsedDisplayed: number }}
51 */
52function getRecentChatsSettings() {
53 const value = accountStorage.getItem(recentChatsSettingsKey);
54 if (value) {
55 try {
56 const parsed = JSON.parse(value);
57 return {
58 maxDisplayed: Math.max(1, parseInt(parsed.maxDisplayed) || DEFAULT_MAX_DISPLAYED),
59 collapsedDisplayed: Math.max(1, parseInt(parsed.collapsedDisplayed) || DEFAULT_COLLAPSED_DISPLAYED),
60 };
61 } catch {
62 // Ignore parse errors
63 }
64 }
65 return { maxDisplayed: DEFAULT_MAX_DISPLAYED, collapsedDisplayed: DEFAULT_COLLAPSED_DISPLAYED };
66}
67
68/**
69 * Saves recent chats settings to account storage.
70 * @param {{ maxDisplayed: number, collapsedDisplayed: number }} settings
71 */
72function saveRecentChatsSettings(settings) {
73 accountStorage.setItem(recentChatsSettingsKey, JSON.stringify(settings));
74}
75
76/**
77 * @typedef {Pick<RecentChat, 'group' | 'avatar' | 'file_name'>} PinnedChat
78 */
79
80/**
81 * Manages pinned chat storage and operations.
82 */
83class PinnedChatsManager {
84 /** @type {Record<string, PinnedChat> | null} */
85 static #cachedState = null;
86
87 /**
88 * Initializes the cached state from storage.
89 * Should be called once on app init.
90 */
91 static init() {
92 this.#cachedState = this.#loadFromStorage();
93 }
94
95 /**
96 * Loads state from storage.
97 * @returns {Record<string, PinnedChat>}
98 */
99 static #loadFromStorage() {
100 const pinnedState = /** @type {Record<string, PinnedChat>} */ ({});
101 const value = accountStorage.getItem(pinnedChatsKey);
102 if (value) {
103 try {
104 Object.assign(pinnedState, JSON.parse(value));
105 } catch (error) {
106 console.warn('Failed to parse pinned chats from storage.', error);
107 }
108 }
109 return pinnedState;
110 }
111
112 /**
113 * Generates a key for pinned chat storage.
114 * @param {Partial<RecentChat>} recentChat Recent chat data
115 * @returns {string} Key for pinned chat storage
116 */
117 static getKey(recentChat) {
118 return `${recentChat.group ? 'group_' + recentChat.group : ''}${recentChat.avatar ? 'char_' + recentChat.avatar : ''}_${recentChat.file_name}`;
119 }
120
121 /**
122 * Gets the pinned chat state from cache.
123 * @returns {Record<string, PinnedChat>}
124 */
125 static getState() {
126 if (this.#cachedState === null) {
127 this.#cachedState = this.#loadFromStorage();
128 }
129 return this.#cachedState;
130 }
131
132 /**
133 * Saves the pinned chat state to storage and updates cache.
134 * @param {Record<string, PinnedChat>} state The state to save
135 */
136 static #saveState(state) {
137 this.#cachedState = state;
138 accountStorage.setItem(pinnedChatsKey, JSON.stringify(state));
139 }
140
141 /**
142 * Checks if a chat is pinned.
143 * @param {RecentChat} recentChat Recent chat data
144 * @returns {boolean} True if the chat is pinned, false otherwise
145 */
146 static isPinned(recentChat) {
147 const pinKey = this.getKey(recentChat);
148 const pinState = this.getState();
149 return pinKey in pinState;
150 }
151
152 /**
153 * Toggles the pinned state of a chat.
154 * @param {RecentChat} recentChat Recent chat data
155 * @param {boolean} pinned New pinned state
156 */
157 static toggle(recentChat, pinned) {
158 const pinKey = this.getKey(recentChat);
159 const pinState = { ...this.getState() };
160 if (pinned) {
161 pinState[pinKey] = {
162 group: recentChat.group,
163 avatar: recentChat.avatar,
164 file_name: recentChat.file_name,
165 };
166 } else {
167 delete pinState[pinKey];
168 }
169 this.#saveState(pinState);
170 }
171
172 /**
173 * Migrates pinned state when a chat is renamed.
174 * @param {Partial<RecentChat>} recentChat Recent chat data (with original file_name)
175 * @param {string} newFileName New file name after rename
176 */
177 static rename(recentChat, newFileName) {
178 const oldKey = this.getKey(recentChat);
179 const pinState = { ...this.getState() };
180 if (!(oldKey in pinState)) {
181 return;
182 }
183 const updatedChat = { ...recentChat, file_name: newFileName };
184 const newKey = this.getKey(updatedChat);
185 pinState[newKey] = {
186 group: recentChat.group,
187 avatar: recentChat.avatar,
188 file_name: newFileName,
189 };
190 delete pinState[oldKey];
191 this.#saveState(pinState);
192 }
193
194 /**
195 * Gets all pinned chats.
196 * @returns {PinnedChat[]}
197 */
198 static getAll() {
199 const pinState = this.getState();
200 return Object.values(pinState);
201 }
202}
203
204export function getPermanentAssistantAvatar() {
205 const assistantAvatar = accountStorage.getItem(assistantAvatarKey);
206 if (assistantAvatar === null) {
207 return defaultAssistantAvatar;
208 }
209
210 const character = characters.find(x => x.avatar === assistantAvatar);
211 if (character === undefined) {
212 accountStorage.removeItem(assistantAvatarKey);
213 return defaultAssistantAvatar;
214 }
215
216 return assistantAvatar;
217}
218
219/**
220 * Opens a welcome screen if no chat is currently active.
221 * @param {object} param Additional parameters
222 * @param {boolean} [param.force] If true, forces clearing of the welcome screen.
223 * @param {boolean} [param.expand] If true, expands the recent chats section.
224 * @returns {Promise<void>}
225 */
226export async function openWelcomeScreen({ force = false, expand = false } = {}) {
227 const currentChatId = getCurrentChatId();
228 if (currentChatId !== undefined || (chat.length > 0 && !force)) {
229 return;
230 }
231
232 const recentChats = await getRecentChats();
233 const chatAfterFetch = getCurrentChatId();
234 if (chatAfterFetch !== currentChatId) {
235 console.debug('Chat changed while fetching recent chats.');
236 return;
237 }
238
239 if (chatAfterFetch === undefined && force) {
240 console.debug('Forcing welcome screen open.');
241 chat.splice(0, chat.length);
242 $('#chat').empty();
243 }
244
245 await sendWelcomePanel(recentChats, expand);
246 await unshallowPermanentAssistant();
247 sendAssistantMessage();
248 sendWelcomePrompt();
249}
250
251/**
252 * Makes sure the assistant character has all data loaded.
253 * @returns {Promise<void>}
254 */
255async function unshallowPermanentAssistant() {
256 const assistantAvatar = getPermanentAssistantAvatar();
257 const characterId = characters.findIndex(x => x.avatar === assistantAvatar);
258 if (characterId === -1) {
259 return;
260 }
261
262 await unshallowCharacter(String(characterId));
263}
264
265/**
266 * Returns a greeting message for the assistant based on the character.
267 * @param {Character} character Character data
268 * @returns {string} Greeting message
269*/
270function getAssistantGreeting(character) {
271 const defaultGreeting = 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.`;
272
273 if (!character) {
274 return defaultGreeting;
275 }
276
277 return getRegexedString(character.first_mes || '', regex_placement.AI_OUTPUT, { depth: 0 }) || defaultGreeting;
278}
279
280function sendAssistantMessage() {
281 const currentAssistantAvatar = getPermanentAssistantAvatar();
282 const character = characters.find(x => x.avatar === currentAssistantAvatar);
283 const name = character ? character.name : neutralCharacterName;
284 const avatar = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar;
285 const greeting = getAssistantGreeting(character);
286
287 const message = {
288 name: name,
289 force_avatar: avatar,
290 mes: greeting,
291 is_system: false,
292 is_user: false,
293 send_date: getMessageTimeStamp(),
294 extra: {
295 type: system_message_types.ASSISTANT_MESSAGE,
296 swipeable: false,
297 },
298 };
299
300 chat.push(message);
301 addOneMessage(message, { scroll: false });
302}
303
304function sendWelcomePrompt() {
305 const message = getSystemMessageByType(system_message_types.WELCOME_PROMPT);
306 chat.push(message);
307 addOneMessage(message, { scroll: false });
308}
309
310/**
311 * Sends the welcome panel to the chat.
312 * @param {RecentChat[]} chats List of recent chats
313 * @param {boolean} [expand=false] If true, expands the recent chats section
314 */
315async function sendWelcomePanel(chats, expand = false) {
316 try {
317 const chatElement = document.getElementById('chat');
318 const sendTextArea = document.getElementById('send_textarea');
319 if (!chatElement) {
320 console.error('Chat element not found');
321 return;
322 }
323 const templateData = {
324 chats,
325 empty: !chats.length,
326 version: displayVersion,
327 more: chats.some(chat => chat.hidden),
328 };
329 const template = await renderTemplateAsync('welcomePanel', templateData);
330 const fragment = document.createRange().createContextualFragment(template);
331 fragment.querySelectorAll('.welcomePanel').forEach((root) => {
332 const recentHiddenClass = 'recentHidden';
333 const recentHiddenKey = 'WelcomePage_RecentChatsHidden';
334 if (accountStorage.getItem(recentHiddenKey) === 'true') {
335 root.classList.add(recentHiddenClass);
336 }
337 root.querySelectorAll('.showRecentChats').forEach((button) => {
338 button.addEventListener('click', () => {
339 root.classList.remove(recentHiddenClass);
340 accountStorage.setItem(recentHiddenKey, 'false');
341 });
342 });
343 root.querySelectorAll('.hideRecentChats').forEach((button) => {
344 button.addEventListener('click', () => {
345 root.classList.add(recentHiddenClass);
346 accountStorage.setItem(recentHiddenKey, 'true');
347 });
348 });
349 root.querySelectorAll('.recentChatsSettings').forEach((button) => {
350 button.addEventListener('click', async (event) => {
351 event.stopPropagation();
352 await openRecentChatsSettingsPopup();
353 });
354 });
355 });
356 fragment.querySelectorAll('.recentChat').forEach((item) => {
357 item.addEventListener('click', () => {
358 const avatarId = item.getAttribute('data-avatar');
359 const groupId = item.getAttribute('data-group');
360 const fileName = item.getAttribute('data-file');
361 if (avatarId && fileName) {
362 void openRecentCharacterChat(avatarId, fileName);
363 }
364 if (groupId && fileName) {
365 void openRecentGroupChat(groupId, fileName);
366 }
367 });
368 });
369 const hiddenChats = fragment.querySelectorAll('.recentChat.hidden');
370 fragment.querySelectorAll('button.showMoreChats').forEach((button) => {
371 const showRecentChatsTitle = t`Show more recent chats`;
372 const hideRecentChatsTitle = t`Show less recent chats`;
373
374 button.setAttribute('title', showRecentChatsTitle);
375 button.addEventListener('click', () => {
376 const rotate = button.classList.contains('rotated');
377 hiddenChats.forEach((chatItem) => {
378 chatItem.classList.toggle('hidden', rotate);
379 });
380 button.classList.toggle('rotated', !rotate);
381 button.setAttribute('title', rotate ? showRecentChatsTitle : hideRecentChatsTitle);
382 });
383 });
384 fragment.querySelectorAll('button.openTemporaryChat').forEach((button) => {
385 button.addEventListener('click', async () => {
386 await newAssistantChat({ temporary: true });
387 if (sendTextArea instanceof HTMLTextAreaElement) {
388 sendTextArea.focus();
389 }
390 });
391 });
392 fragment.querySelectorAll('.recentChat.group').forEach((groupChat) => {
393 const groupId = groupChat.getAttribute('data-group');
394 const group = groups.find(x => x.id === groupId);
395 if (group) {
396 const avatar = groupChat.querySelector('.avatar');
397 if (!avatar) {
398 return;
399 }
400 const groupAvatar = getGroupAvatar(group);
401 $(avatar).replaceWith(groupAvatar);
402 }
403 });
404 fragment.querySelectorAll('.recentChat .renameChat').forEach((renameButton) => {
405 renameButton.addEventListener('click', (event) => {
406 event.stopPropagation();
407 const chatItem = renameButton.closest('.recentChat');
408 if (!chatItem) {
409 return;
410 }
411 const avatarId = chatItem.getAttribute('data-avatar');
412 const groupId = chatItem.getAttribute('data-group');
413 const fileName = chatItem.getAttribute('data-file');
414 if (avatarId && fileName) {
415 void renameRecentCharacterChat(avatarId, fileName);
416 }
417 if (groupId && fileName) {
418 void renameRecentGroupChat(groupId, fileName);
419 }
420 });
421 });
422 fragment.querySelectorAll('.recentChat .deleteChat').forEach((deleteButton) => {
423 deleteButton.addEventListener('click', (event) => {
424 event.stopPropagation();
425 const chatItem = deleteButton.closest('.recentChat');
426 if (!chatItem) {
427 return;
428 }
429 const avatarId = chatItem.getAttribute('data-avatar');
430 const groupId = chatItem.getAttribute('data-group');
431 const fileName = chatItem.getAttribute('data-file');
432 if (avatarId && fileName) {
433 void deleteRecentCharacterChat(avatarId, fileName);
434 }
435 if (groupId && fileName) {
436 void deleteRecentGroupChat(groupId, fileName);
437 }
438 });
439 });
440 fragment.querySelectorAll('.recentChat .pinChat').forEach((pinButton) => {
441 pinButton.addEventListener('click', async (event) => {
442 event.stopPropagation();
443 const chatItem = pinButton.closest('.recentChat');
444 if (!chatItem) {
445 return;
446 }
447 const avatarId = chatItem.getAttribute('data-avatar');
448 const groupId = chatItem.getAttribute('data-group');
449 const fileName = chatItem.getAttribute('data-file');
450 const recentChat = chats.find(c => c.chat_name === fileName && ((c.is_group && c.group === groupId) || (!c.is_group && c.avatar === avatarId)));
451 if (!recentChat) {
452 console.error('Recent chat not found for pinning.');
453 return;
454 }
455 const currentlyPinned = PinnedChatsManager.isPinned(recentChat);
456 PinnedChatsManager.toggle(recentChat, !currentlyPinned);
457 await refreshWelcomeScreen({ flashChat: recentChat });
458 });
459 });
460 chatElement.append(fragment.firstChild);
461 if (expand) {
462 chatElement.querySelectorAll('button.showMoreChats').forEach((button) => {
463 if (button instanceof HTMLButtonElement) {
464 button.click();
465 }
466 });
467 }
468 } catch (error) {
469 console.error('Welcome screen error:', error);
470 }
471}
472
473/**
474 * Opens a recent character chat.
475 * @param {string} avatarId Avatar file name
476 * @param {string} fileName Chat file name
477 */
478async function openRecentCharacterChat(avatarId, fileName) {
479 const characterId = characters.findIndex(x => x.avatar === avatarId);
480 if (characterId === -1) {
481 console.error(`Character not found for avatar ID: ${avatarId}`);
482 return;
483 }
484
485 try {
486 await selectCharacterById(characterId);
487 setActiveCharacter(avatarId);
488 saveSettingsDebounced();
489 const currentChatId = getCurrentChatId();
490 if (currentChatId === fileName) {
491 console.debug(`Chat ${fileName} is already open.`);
492 return;
493 }
494 await openCharacterChat(fileName);
495 } catch (error) {
496 console.error('Error opening recent chat:', error);
497 toastr.error(t`Failed to open recent chat. See console for details.`);
498 }
499}
500
501/**
502 * Opens a recent group chat.
503 * @param {string} groupId Group ID
504 * @param {string} fileName Chat file name
505 */
506async function openRecentGroupChat(groupId, fileName) {
507 const group = groups.find(x => x.id === groupId);
508 if (!group) {
509 console.error(`Group not found for ID: ${groupId}`);
510 return;
511 }
512
513 try {
514 await openGroupById(groupId);
515 setActiveGroup(groupId);
516 saveSettingsDebounced();
517 const currentChatId = getCurrentChatId();
518 if (currentChatId === fileName) {
519 console.debug(`Chat ${fileName} is already open.`);
520 return;
521 }
522 await openGroupChat(groupId, fileName);
523 } catch (error) {
524 console.error('Error opening recent group chat:', error);
525 toastr.error(t`Failed to open recent group chat. See console for details.`);
526 }
527}
528
529/**
530 * Renames a recent character chat.
531 * @param {string} avatarId Avatar file name
532 * @param {string} fileName Chat file name
533 */
534async function renameRecentCharacterChat(avatarId, fileName) {
535 const characterId = characters.findIndex(x => x.avatar === avatarId);
536 if (characterId === -1) {
537 console.error(`Character not found for avatar ID: ${avatarId}`);
538 return;
539 }
540 try {
541 const popupText = await renderTemplateAsync('chatRename');
542 const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, fileName);
543 if (!newName || typeof newName !== 'string' || newName === fileName) {
544 console.log('No new name provided, aborting');
545 return;
546 }
547 await renameGroupOrCharacterChat({
548 characterId: String(characterId),
549 oldFileName: fileName,
550 newFileName: newName,
551 loader: false,
552 });
553 await updateRemoteChatName(characterId, newName);
554 await refreshWelcomeScreen();
555 toastr.success(t`Chat renamed.`);
556 } catch (error) {
557 console.error('Error renaming recent character chat:', error);
558 toastr.error(t`Failed to rename recent chat. See console for details.`);
559 }
560}
561
562/**
563 * Renames a recent group chat.
564 * @param {string} groupId Group ID
565 * @param {string} fileName Chat file name
566 */
567async function renameRecentGroupChat(groupId, fileName) {
568 const group = groups.find(x => x.id === groupId);
569 if (!group) {
570 console.error(`Group not found for ID: ${groupId}`);
571 return;
572 }
573 try {
574 const popupText = await renderTemplateAsync('chatRename');
575 const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, fileName);
576 if (!newName || newName === fileName) {
577 console.log('No new name provided, aborting');
578 return;
579 }
580 await renameGroupOrCharacterChat({
581 groupId: String(groupId),
582 oldFileName: fileName,
583 newFileName: String(newName),
584 loader: false,
585 });
586 await refreshWelcomeScreen();
587 toastr.success(t`Group chat renamed.`);
588 } catch (error) {
589 console.error('Error renaming recent group chat:', error);
590 toastr.error(t`Failed to rename recent group chat. See console for details.`);
591 }
592}
593
594/**
595 * Deletes a recent character chat.
596 * @param {string} avatarId Avatar file name
597 * @param {string} fileName Chat file name
598 */
599async function deleteRecentCharacterChat(avatarId, fileName) {
600 const characterId = characters.findIndex(x => x.avatar === avatarId);
601 if (characterId === -1) {
602 console.error(`Character not found for avatar ID: ${avatarId}`);
603 return;
604 }
605 try {
606 const confirm = await callGenericPopup(t`Delete the Chat File?`, POPUP_TYPE.CONFIRM);
607 if (!confirm) {
608 console.log('Deletion cancelled by user');
609 return;
610 }
611 await deleteCharacterChatByName(String(characterId), fileName);
612 await refreshWelcomeScreen();
613 toastr.success(t`Chat deleted.`);
614 } catch (error) {
615 console.error('Error deleting recent character chat:', error);
616 toastr.error(t`Failed to delete recent chat. See console for details.`);
617 }
618}
619
620/**
621 * Deletes a recent group chat.
622 * @param {string} groupId Group ID
623 * @param {string} fileName Chat file name
624 */
625async function deleteRecentGroupChat(groupId, fileName) {
626 const group = groups.find(x => x.id === groupId);
627 if (!group) {
628 console.error(`Group not found for ID: ${groupId}`);
629 return;
630 }
631 try {
632 const confirm = await callGenericPopup(t`Delete the Chat File?`, POPUP_TYPE.CONFIRM);
633 if (!confirm) {
634 console.log('Deletion cancelled by user');
635 return;
636 }
637 await deleteGroupChatByName(groupId, fileName);
638 await refreshWelcomeScreen();
639 toastr.success(t`Group chat deleted.`);
640 } catch (error) {
641 console.error('Error deleting recent group chat:', error);
642 toastr.error(t`Failed to delete recent group chat. See console for details.`);
643 }
644}
645
646/**
647 * Reopens the welcome screen and restores the scroll position.
648 * @param {object} param Additional parameters
649 * @param {RecentChat} [param.flashChat] Recent chat to flash (if any)
650 * @returns {Promise<void>}
651 */
652async function refreshWelcomeScreen({ flashChat = null } = {}) {
653 const chatElement = document.getElementById('chat');
654 if (!chatElement) {
655 console.error('Chat element not found');
656 return;
657 }
658
659 const scrollTop = chatElement.scrollTop;
660 const scrollHeight = chatElement.scrollHeight;
661 const expand = chatElement.querySelectorAll('button.showMoreChats.rotated').length > 0;
662
663 await openWelcomeScreen({ force: true, expand });
664
665 // Restore scroll position or flash specific chat
666 if (flashChat) {
667 const recentChats = Array.from(chatElement.querySelectorAll('.recentChat'));
668 const chatToFlash = recentChats.find(el => {
669 const file = el.getAttribute('data-file');
670 const group = el.getAttribute('data-group');
671 const avatar = el.getAttribute('data-avatar');
672 return file === flashChat.chat_name &&
673 ((flashChat.is_group && group === flashChat.group) || (!flashChat.is_group && avatar === flashChat.avatar));
674 });
675 if (chatToFlash instanceof HTMLElement) {
676 if (!isElementInViewport(chatToFlash)) {
677 chatElement.scrollTop = chatToFlash.offsetTop - chatElement.offsetTop - (chatToFlash.clientHeight / 2);
678 }
679 flashHighlight($(chatToFlash), 1000);
680 }
681 } else {
682 // Restore scroll position
683 chatElement.scrollTop = scrollTop + (chatElement.scrollHeight - scrollHeight);
684 }
685}
686
687/**
688 * Opens a popup to configure recent chats settings.
689 */
690async function openRecentChatsSettingsPopup() {
691 const settings = getRecentChatsSettings();
692
693 const MIN_CHATS = 1;
694 const MAX_CHATS = 1000;
695
696 /** @type {import('./popup.js').CustomPopupInput} */
697 const maxRecentChatsInput = {
698 id: 'maxRecentChats',
699 type: 'number',
700 label: t`Max recent chats`,
701 tooltip: t`${MIN_CHATS} - ${MAX_CHATS}`,
702 defaultState: String(settings.maxDisplayed),
703 min: MIN_CHATS,
704 max: MAX_CHATS,
705 step: 1,
706 };
707
708 /** @type {import('./popup.js').CustomPopupInput} */
709 const collapsedRecentChatsInput = {
710 id: 'collapsedRecentChats',
711 type: 'number',
712 label: t`Collapsed recent chats`,
713 tooltip: t`${MIN_CHATS} - ${MAX_CHATS}`,
714 defaultState: String(settings.collapsedDisplayed),
715 min: MIN_CHATS,
716 max: MAX_CHATS,
717 step: 1,
718 };
719
720 await callGenericPopup(t`Recent Chats Settings`, POPUP_TYPE.CONFIRM, null, {
721 okButton: t`Save`,
722 cancelButton: t`Cancel`,
723 customInputs: [maxRecentChatsInput, collapsedRecentChatsInput],
724 onClose: (popup) => {
725 if (!popup.result) {
726 return;
727 }
728
729 const maxInputValue = popup.inputResults.get(maxRecentChatsInput.id)?.toString() ?? String(DEFAULT_MAX_DISPLAYED);
730 const collapsedInputValue = popup.inputResults.get(collapsedRecentChatsInput.id)?.toString() ?? String(DEFAULT_COLLAPSED_DISPLAYED);
731
732 const newMax = clamp(parseInt(maxInputValue) || DEFAULT_MAX_DISPLAYED, maxRecentChatsInput.min, maxRecentChatsInput.max);
733 const newCollapsed = clamp(parseInt(collapsedInputValue) || DEFAULT_COLLAPSED_DISPLAYED, collapsedRecentChatsInput.min, newMax);
734
735 saveRecentChatsSettings({ maxDisplayed: newMax, collapsedDisplayed: newCollapsed });
736 },
737 });
738
739 await refreshWelcomeScreen();
740}
741
742/**
743 * Gets the list of recent chats from the server.
744 * @returns {Promise<RecentChat[]>} List of recent chats
745 *
746 * @typedef {object} RecentChat
747 * @property {string} file_name Name of the chat file
748 * @property {string} chat_name Name of the chat (without extension)
749 * @property {string} file_size Size of the chat file
750 * @property {number} chat_items Number of items in the chat
751 * @property {string} mes Last message content
752 * @property {string} last_mes Timestamp of the last message
753 * @property {string} avatar Avatar URL
754 * @property {string} char_thumbnail Thumbnail URL
755 * @property {string} char_name Character or group name
756 * @property {string} date_short Date in short format
757 * @property {string} date_long Date in long format
758 * @property {string} group Group ID (if applicable)
759 * @property {boolean} is_group Indicates if the chat is a group chat
760 * @property {boolean} hidden Chat will be hidden by default
761 * @property {boolean} pinned Indicates if the chat is pinned
762 */
763async function getRecentChats() {
764 const settings = getRecentChatsSettings();
765 const response = await fetch('/api/chats/recent', {
766 method: 'POST',
767 headers: getRequestHeaders(),
768 body: JSON.stringify({ max: settings.maxDisplayed, pinned: PinnedChatsManager.getAll() }),
769 cache: 'no-cache',
770 });
771
772 if (!response.ok) {
773 console.warn('Failed to fetch recent character chats');
774 return [];
775 }
776
777 /** @type {RecentChat[]} */
778 const data = await response.json();
779
780 if (!Array.isArray(data) || data.length === 0) {
781 return [];
782 }
783
784 const dataWithEntities = data
785 .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) }))
786 .filter(t => t.character || t.group)
787 .sort((a, b) => {
788 const isAPinned = PinnedChatsManager.isPinned(a.chat);
789 const isBPinned = PinnedChatsManager.isPinned(b.chat);
790 const momentComparison = sortMoments(timestampToMoment(a.chat.last_mes), timestampToMoment(b.chat.last_mes));
791
792 if (isAPinned && !isBPinned) {
793 return -1;
794 }
795 if (!isAPinned && isBPinned) {
796 return 1;
797 }
798
799 return momentComparison;
800 });
801
802 dataWithEntities.forEach(({ chat, character, group }, index) => {
803 const chatTimestamp = timestampToMoment(chat.last_mes);
804 chat.char_name = character?.name || group?.name || '';
805 chat.date_short = chatTimestamp.format('l');
806 chat.date_long = chatTimestamp.format('LL LT');
807 chat.chat_name = chat.file_name.replace('.jsonl', '');
808 chat.char_thumbnail = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar;
809 chat.is_group = !!group;
810 chat.hidden = index >= settings.collapsedDisplayed;
811 chat.avatar = chat.avatar || '';
812 chat.group = chat.group || '';
813 chat.pinned = PinnedChatsManager.isPinned(chat);
814 });
815
816 return dataWithEntities.map(t => t.chat);
817}
818
819export async function openPermanentAssistantChat({ tryCreate = true, created = false } = {}) {
820 const avatar = getPermanentAssistantAvatar();
821 const characterId = characters.findIndex(x => x.avatar === avatar);
822 if (characterId === -1) {
823 if (!tryCreate) {
824 console.error(`Character not found for avatar ID: ${avatar}. Cannot create.`);
825 return;
826 }
827
828 try {
829 console.log(`Character not found for avatar ID: ${avatar}. Creating new assistant.`);
830 await createPermanentAssistant();
831 return openPermanentAssistantChat({ tryCreate: false, created: true });
832 } catch (error) {
833 console.error('Error creating permanent assistant:', error);
834 toastr.error(t`Failed to create ${neutralCharacterName}. See console for details.`);
835 return;
836 }
837 }
838
839 try {
840 await selectCharacterById(characterId);
841 if (!created) {
842 await doNewChat({ deleteCurrentChat: false });
843 }
844 console.log(`Opened permanent assistant chat for ${neutralCharacterName}.`, getCurrentChatId());
845 } catch (error) {
846 console.error('Error opening permanent assistant chat:', error);
847 toastr.error(t`Failed to open permanent assistant chat. See console for details.`);
848 }
849}
850
851async function createPermanentAssistant() {
852 if (is_group_generating || is_send_press) {
853 throw new Error(t`Cannot create while generating.`);
854 }
855
856 const formData = new FormData();
857 formData.append('ch_name', neutralCharacterName);
858 formData.append('file_name', defaultAssistantAvatar.replace('.png', ''));
859 formData.append('creator_notes', t`Automatically created character. Feel free to edit.`);
860
861 try {
862 const avatarResponse = await fetch(system_avatar);
863 const avatarBlob = await avatarResponse.blob();
864 formData.append('avatar', avatarBlob, defaultAssistantAvatar);
865 } catch (error) {
866 console.warn('Error fetching system avatar. Fallback image will be used.', error);
867 }
868
869 const fetchResult = await fetch('/api/characters/create', {
870 method: 'POST',
871 headers: getRequestHeaders({ omitContentType: true }),
872 body: formData,
873 cache: 'no-cache',
874 });
875
876 if (!fetchResult.ok) {
877 throw new Error(t`Creation request did not succeed.`);
878 }
879
880 await getCharacters();
881}
882
883export async function openPermanentAssistantCard() {
884 const avatar = getPermanentAssistantAvatar();
885 const characterId = characters.findIndex(x => x.avatar === avatar);
886 if (characterId === -1) {
887 toastr.info(t`Assistant not found. Try sending a chat message.`);
888 return;
889 }
890
891 await selectCharacterById(characterId);
892}
893
894/**
895 * Assigns a character as the assistant.
896 * @param {string?} characterId Character ID
897 */
898export function assignCharacterAsAssistant(characterId) {
899 if (characterId === undefined) {
900 return;
901 }
902 /** @type {Character} */
903 const character = characters[characterId];
904 if (!character) {
905 return;
906 }
907
908 const currentAssistantAvatar = getPermanentAssistantAvatar();
909 if (currentAssistantAvatar === character.avatar) {
910 if (character.avatar === defaultAssistantAvatar) {
911 toastr.info(t`${character.name} is a system assistant. Choose another character.`);
912 return;
913 }
914
915 toastr.info(t`${character.name} is no longer your assistant.`);
916 accountStorage.removeItem(assistantAvatarKey);
917 return;
918 }
919
920 accountStorage.setItem(assistantAvatarKey, character.avatar);
921 printCharactersDebounced();
922 toastr.success(t`Set ${character.name} as your assistant.`);
923}
924
925export function initWelcomeScreen() {
926 PinnedChatsManager.init();
927
928 const events = [event_types.CHAT_CHANGED, event_types.APP_READY];
929 for (const event of events) {
930 eventSource.makeFirst(event, openWelcomeScreen);
931 }
932
933 eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => {
934 if (target !== 'set_as_assistant') {
935 return;
936 }
937 assignCharacterAsAssistant(this_chid);
938 });
939
940 eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => {
941 if (oldAvatar === getPermanentAssistantAvatar()) {
942 accountStorage.setItem(assistantAvatarKey, newAvatar);
943 }
944 });
945
946 eventSource.on(event_types.CHAT_RENAMED, async ({ avatarId, groupId, oldFileName, newFileName }) => {
947 PinnedChatsManager.rename({ avatar: avatarId, group: groupId, file_name: oldFileName }, newFileName);
948 });
949}