| 1 | import { |
| 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'; |
| 31 | import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; |
| 32 | import { deleteGroupChatByName, getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js'; |
| 33 | import { t } from './i18n.js'; |
| 34 | import { callGenericPopup, POPUP_TYPE } from './popup.js'; |
| 35 | import { getMessageTimeStamp } from './RossAscends-mods.js'; |
| 36 | import { renderTemplateAsync } from './templates.js'; |
| 37 | import { accountStorage } from './util/AccountStorage.js'; |
| 38 | import { clamp, flashHighlight, isElementInViewport, sortMoments, timestampToMoment } from './utils.js'; |
| 39 | |
| 40 | const assistantAvatarKey = 'assistant'; |
| 41 | const pinnedChatsKey = 'pinnedChats'; |
| 42 | const recentChatsSettingsKey = 'recentChatsSettings'; |
| 43 | const defaultAssistantAvatar = 'default_Assistant.png'; |
| 44 | |
| 45 | const DEFAULT_MAX_DISPLAYED = 15; |
| 46 | const DEFAULT_COLLAPSED_DISPLAYED = 3; |
| 47 | |
| 48 | /** |
| 49 | * Gets the current recent chats settings from account storage. |
| 50 | * @returns {{ maxDisplayed: number, collapsedDisplayed: number }} |
| 51 | */ |
| 52 | function 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 | */ |
| 72 | function 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 | */ |
| 83 | class 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 | |
| 204 | export 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 | */ |
| 226 | export 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 | */ |
| 255 | async 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 | */ |
| 270 | function 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 | |
| 280 | function 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 | |
| 304 | function 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 | */ |
| 315 | async 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 | */ |
| 478 | async 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 | */ |
| 506 | async 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 | */ |
| 534 | async 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 | */ |
| 567 | async 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 | */ |
| 599 | async 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 | */ |
| 625 | async 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 | */ |
| 652 | async 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 | */ |
| 690 | async 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 | */ |
| 763 | async 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 | |
| 819 | export 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 | |
| 851 | async 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 | |
| 883 | export 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 | */ |
| 898 | export 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 | |
| 925 | export 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 | } |