Merge pull request #3990 from SillyTavern/welcome-screen New welcome screen + quick chat functionality
Signed| @@ -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 | +} | |
| @@ -5362,6 +5362,9 @@ | ||
| 5362 | 5362 | <option id="import_tags" data-i18n="Import Tags"> |
| 5363 | 5363 | Import Tags |
| 5364 | 5364 | </option> |
| 5365 | + <option id="set_as_assistant" data-i18n="Set / Unset as Welcome Page Assistant"> | |
| 5366 | + Set / Unset as Welcome Page Assistant | |
| 5367 | + </option> | |
| 5365 | 5368 | <!--<option id="dupe_button"> |
| 5366 | 5369 | Duplicate |
| 5367 | 5370 | </option> |
| @@ -6383,6 +6386,9 @@ | ||
| 6383 | 6386 | <div class="wide100p character_name_block"> |
| 6384 | 6387 | <span class="ch_name"></span> |
| 6385 | 6388 | <small class="ch_additional_info ch_add_placeholder">+++</small> |
| 6389 | + <small class="ch_assistant" title="This character will be used as a welcome page assistant." data-i18n="[title]This character will be used as a welcome page assistant."> | |
| 6390 | + <i class="fa-solid fa-sm fa-user-graduate"></i> | |
| 6391 | + </small> | |
| 6386 | 6392 | <small class="ch_additional_info character_version"></small> |
| 6387 | 6393 | <small class="ch_additional_info ch_avatar_url"></small> |
| 6388 | 6394 | </div> |
| @@ -282,6 +282,7 @@ import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js'; | ||
| 282 | 282 | import { getContext } from './scripts/st-context.js'; |
| 283 | 283 | import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js'; |
| 284 | 284 | import { accountStorage } from './scripts/util/AccountStorage.js'; |
| 285 | +import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js'; | |
| 285 | 286 | |
| 286 | 287 | // API OBJECT FOR EXTERNAL WIRING |
| 287 | 288 | globalThis.SillyTavern = { |
| @@ -529,6 +530,7 @@ export const event_types = { | ||
| 529 | 530 | CONNECTION_PROFILE_UPDATED: 'connection_profile_updated', |
| 530 | 531 | TOOL_CALLS_PERFORMED: 'tool_calls_performed', |
| 531 | 532 | TOOL_CALLS_RENDERED: 'tool_calls_rendered', |
| 533 | + CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown', | |
| 532 | 534 | }; |
| 533 | 535 | |
| 534 | 536 | export const eventSource = new EventEmitter([event_types.APP_READY]); |
| @@ -563,7 +565,7 @@ let chat_create_date = ''; | ||
| 563 | 565 | let firstRun = false; |
| 564 | 566 | let settingsReady = false; |
| 565 | 567 | let currentVersion = '0.0.0'; |
| 566 | 568 | export let displayVersion = 'SillyTavern'; |
| 567 | 569 | |
| 568 | 570 | let generatedPromptCache = ''; |
| 569 | 571 | let generation_started = new Date(); |
| @@ -636,6 +638,7 @@ export const system_message_types = { | ||
| 636 | 638 | MACROS: 'macros', |
| 637 | 639 | WELCOME_PROMPT: 'welcome_prompt', |
| 638 | 640 | ASSISTANT_NOTE: 'assistant_note', |
| 641 | + ASSISTANT_MESSAGE: 'assistant_message', | |
| 639 | 642 | }; |
| 640 | 643 | |
| 641 | 644 | /** |
| @@ -737,6 +740,7 @@ async function getSystemMessages() { | ||
| 737 | 740 | force_avatar: system_avatar, |
| 738 | 741 | is_user: false, |
| 739 | 742 | is_system: true, |
| 743 | + uses_system_ui: true, | |
| 740 | 744 | mes: await renderTemplateAsync('welcomePrompt'), |
| 741 | 745 | extra: { |
| 742 | 746 | isSmallSys: true, |
| @@ -983,8 +987,6 @@ async function firstLoadInit() { | ||
| 983 | 987 | ToolManager.initToolSlashCommands(); |
| 984 | 988 | await initPresetManager(); |
| 985 | 989 | await getSystemMessages(); |
| 986 | - sendSystemMessage(system_message_types.WELCOME); | |
| 987 | - sendSystemMessage(system_message_types.WELCOME_PROMPT); | |
| 988 | 990 | await getSettings(); |
| 989 | 991 | initKeyboard(); |
| 990 | 992 | initDynamicStyles(); |
| @@ -1009,6 +1011,7 @@ async function firstLoadInit() { | ||
| 1009 | 1011 | initSettingsSearch(); |
| 1010 | 1012 | initBulkEdit(); |
| 1011 | 1013 | initReasoning(); |
| 1014 | + initWelcomeScreen(); | |
| 1012 | 1015 | await initScrapers(); |
| 1013 | 1016 | initCustomSelectedSamplers(); |
| 1014 | 1017 | addDebugFunctions(); |
| @@ -1472,6 +1475,11 @@ function getCharacterBlock(item, id) { | ||
| 1472 | 1475 | template.toggleClass('is_fav', item.fav || item.fav == 'true'); |
| 1473 | 1476 | template.find('.ch_fav').val(item.fav); |
| 1474 | 1477 | |
| 1478 | + const isAssistant = item.avatar === getPermanentAssistantAvatar(); | |
| 1479 | + if (!isAssistant) { | |
| 1480 | + template.find('.ch_assistant').remove(); | |
| 1481 | + } | |
| 1482 | + | |
| 1475 | 1483 | const description = item.data?.creator_notes || ''; |
| 1476 | 1484 | if (description) { |
| 1477 | 1485 | template.find('.ch_description').text(description); |
| @@ -2040,7 +2048,7 @@ export async function sendTextareaMessage() { | ||
| 2040 | 2048 | } |
| 2041 | 2049 | |
| 2042 | 2050 | if (textareaText && !selected_group && this_chid === undefined && name2 !== neutralCharacterName) { |
| 2043 | 2051 | await newAssistantChat({ temporary: false }); |
| 2044 | 2052 | } |
| 2045 | 2053 | |
| 2046 | 2054 | Generate(generateType); |
| @@ -2291,6 +2299,7 @@ function getMessageFromTemplate({ | ||
| 2291 | 2299 | timestamp, |
| 2292 | 2300 | tokenCount, |
| 2293 | 2301 | extra, |
| 2302 | + type, | |
| 2294 | 2303 | }) { |
| 2295 | 2304 | const mes = messageTemplate.clone(); |
| 2296 | 2305 | mes.attr({ |
| @@ -2302,6 +2311,7 @@ function getMessageFromTemplate({ | ||
| 2302 | 2311 | 'bookmark_link': bookmarkLink, |
| 2303 | 2312 | 'force_avatar': !!forceAvatar, |
| 2304 | 2313 | 'timestamp': timestamp, |
| 2314 | + ...(type ? { type } : {}), | |
| 2305 | 2315 | }); |
| 2306 | 2316 | mes.find('.avatar img').attr('src', avatarImg); |
| 2307 | 2317 | mes.find('.ch_name .name_text').text(characterName); |
| @@ -2513,6 +2523,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | ||
| 2513 | 2523 | timestamp: timestamp, |
| 2514 | 2524 | extra: mes.extra, |
| 2515 | 2525 | tokenCount: mes.extra?.token_count ?? 0, |
| 2526 | + type: mes.extra?.type ?? '', | |
| 2516 | 2527 | ...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token), |
| 2517 | 2528 | }; |
| 2518 | 2529 | |
| @@ -2882,7 +2893,14 @@ export async function processCommands(message) { | ||
| 2882 | 2893 | return true; |
| 2883 | 2894 | } |
| 2884 | 2895 | |
| 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 = {}) { | |
| 2886 | 2904 | const systemMessage = system_messages[type]; |
| 2887 | 2905 | |
| 2888 | 2906 | if (!systemMessage) { |
| @@ -2905,7 +2923,17 @@ export function sendSystemMessage(type, text, extra = {}) { | ||
| 2905 | 2923 | |
| 2906 | 2924 | newMessage.extra = Object.assign(newMessage.extra, extra); |
| 2907 | 2925 | newMessage.extra.type = type; |
| 2926 | + return newMessage; | |
| 2927 | +} | |
| 2908 | 2928 | |
| 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); | |
| 2909 | 2937 | chat.push(newMessage); |
| 2910 | 2938 | addOneMessage(newMessage); |
| 2911 | 2939 | is_send_press = false; |
| @@ -10112,8 +10140,17 @@ async function removeCharacterFromUI() { | ||
| 10112 | 10140 | saveSettingsDebounced(); |
| 10113 | 10141 | } |
| 10114 | 10142 | |
| 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 } = {}) { | |
| 10116 | 10150 | await clearChat(); |
| 10151 | + if (!temporary) { | |
| 10152 | + return openPermanentAssistantChat(); | |
| 10153 | + } | |
| 10117 | 10154 | chat.splice(0, chat.length); |
| 10118 | 10155 | chat_metadata = {}; |
| 10119 | 10156 | setCharacterName(neutralCharacterName); |
| @@ -10426,7 +10463,7 @@ jQuery(async function () { | ||
| 10426 | 10463 | if (chatId) { |
| 10427 | 10464 | return reject('Not in a temporary chat'); |
| 10428 | 10465 | } |
| 10429 | 10466 | await newAssistantChat({ temporary: true }); |
| 10430 | 10467 | return resolve(''); |
| 10431 | 10468 | }; |
| 10432 | 10469 | eventSource.once(event_types.CHAT_CHANGED, eventCallback); |
| @@ -11093,6 +11130,9 @@ jQuery(async function () { | ||
| 11093 | 11130 | }); |
| 11094 | 11131 | |
| 11095 | 11132 | if (id == 'option_select_chat') { |
| 11133 | + if (this_chid === undefined && !is_send_press) { | |
| 11134 | + await openPermanentAssistantCard(); | |
| 11135 | + } | |
| 11096 | 11136 | if ((selected_group && !is_group_generating) || (this_chid !== undefined && !is_send_press) || fromSlashCommand) { |
| 11097 | 11137 | await displayPastChats(); |
| 11098 | 11138 | //this is just to avoid the shadow for past chat view when using /delchat |
| @@ -11123,7 +11163,7 @@ jQuery(async function () { | ||
| 11123 | 11163 | await doNewChat({ deleteCurrentChat: deleteCurrentChat }); |
| 11124 | 11164 | } |
| 11125 | 11165 | if (!selected_group && this_chid === undefined && !is_send_press) { |
| 11126 | 11166 | await newAssistantChat({ temporary: true }); |
| 11127 | 11167 | } |
| 11128 | 11168 | } |
| 11129 | 11169 | |
| @@ -11176,9 +11216,6 @@ jQuery(async function () { | ||
| 11176 | 11216 | selected_button = 'characters'; |
| 11177 | 11217 | $('#rm_button_selected_ch').children('h2').text(''); |
| 11178 | 11218 | select_rm_characters(); |
| 11179 | - sendSystemMessage(system_message_types.WELCOME); | |
| 11180 | - sendSystemMessage(system_message_types.WELCOME_PROMPT); | |
| 11181 | - await getClientVersion(); | |
| 11182 | 11219 | await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId()); |
| 11183 | 11220 | } else { |
| 11184 | 11221 | toastr.info('Please stop the message generation first.'); |
| @@ -12108,7 +12145,7 @@ jQuery(async function () { | ||
| 12108 | 12145 | ); |
| 12109 | 12146 | break;*/ |
| 12110 | 12147 | default: |
| 12111 | 12148 | await eventSource.emit('charManagementDropdown'event_types.CHARACTER_MANAGEMENT_DROPDOWN, target); |
| 12112 | 12149 | } |
| 12113 | 12150 | $('#char-management-dropdown').prop('selectedIndex', 0); |
| 12114 | 12151 | }); |
| @@ -23,6 +23,9 @@ import { | ||
| 23 | 23 | neutralCharacterName, |
| 24 | 24 | updateChatMetadata, |
| 25 | 25 | system_message_types, |
| 26 | + getSystemMessageByType, | |
| 27 | + printMessages, | |
| 28 | + clearChat, | |
| 26 | 29 | } from '../script.js'; |
| 27 | 30 | import { selected_group } from './group-chats.js'; |
| 28 | 31 | import { power_user } from './power-user.js'; |
| @@ -37,6 +40,7 @@ import { | ||
| 37 | 40 | saveBase64AsFile, |
| 38 | 41 | extractTextFromOffice, |
| 39 | 42 | download, |
| 43 | + getFileText, | |
| 40 | 44 | } from './utils.js'; |
| 41 | 45 | import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; |
| 42 | 46 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| @@ -1497,6 +1501,35 @@ jQuery(function () { | ||
| 1497 | 1501 | download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json'); |
| 1498 | 1502 | }); |
| 1499 | 1503 | |
| 1504 | + $(document).on('click', '.assistant_note_import', async function () { | |
| 1505 | + const importFile = async () => { | |
| 1506 | + const file = fileInput.files[0]; | |
| 1507 | + if (!file) { | |
| 1508 | + return; | |
| 1509 | + } | |
| 1510 | + | |
| 1511 | + try { | |
| 1512 | + const text = await getFileText(file); | |
| 1513 | + const lines = text.split('\n').filter(line => line.trim() !== ''); | |
| 1514 | + const messages = lines.map(line => JSON.parse(line)); | |
| 1515 | + const metadata = messages.shift()?.chat_metadata || {}; | |
| 1516 | + messages.unshift(getSystemMessageByType(system_message_types.ASSISTANT_NOTE)); | |
| 1517 | + await clearChat(); | |
| 1518 | + chat.splice(0, chat.length, ...messages); | |
| 1519 | + updateChatMetadata(metadata, true); | |
| 1520 | + await printMessages(); | |
| 1521 | + } catch (error) { | |
| 1522 | + console.error('Error importing assistant chat:', error); | |
| 1523 | + toastr.error(t`It's either corrupted or not a valid JSONL file.`, t`Failed to import chat`); | |
| 1524 | + } | |
| 1525 | + }; | |
| 1526 | + const fileInput = document.createElement('input'); | |
| 1527 | + fileInput.type = 'file'; | |
| 1528 | + fileInput.accept = '.jsonl'; | |
| 1529 | + fileInput.addEventListener('change', importFile); | |
| 1530 | + fileInput.click(); | |
| 1531 | + }); | |
| 1532 | + | |
| 1500 | 1533 | // Do not change. #attachFile is added by extension. |
| 1501 | 1534 | $(document).on('click', '#attachFile', function () { |
| 1502 | 1535 | $('#file_form_input').trigger('click'); |
| @@ -710,7 +710,7 @@ async function listGalleryCommand(args) { | ||
| 710 | 710 | delete context.extensionSettings.gallery.folders[avatar]; |
| 711 | 711 | context.saveSettingsDebounced(); |
| 712 | 712 | }); |
| 713 | 713 | eventSource.on('charManagementDropdown'event_types.CHARACTER_MANAGEMENT_DROPDOWN, (selectedOptionId) => { |
| 714 | 714 | if (selectedOptionId === 'show_char_gallery') { |
| 715 | 715 | showCharGallery(); |
| 716 | 716 | } |
| @@ -1970,7 +1970,7 @@ export async function initPersonas() { | ||
| 1970 | 1970 | |
| 1971 | 1971 | $('#char_connections_button').on('click', showCharConnections); |
| 1972 | 1972 | |
| 1973 | 1973 | eventSource.on('charManagementDropdown'event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => { |
| 1974 | 1974 | if (target === 'convert_to_persona') { |
| 1975 | 1975 | convertCharacterToPersona(); |
| 1976 | 1976 | } |
| @@ -1,9 +1,13 @@ | ||
| 1 | 1 | <div data-type="assistant_note"> |
| 2 | 2 | <div class="assistant_note_title"> |
| 3 | 3 | <b data-i18n="Note:">Note:</b> <span data-i18n="this chat is temporary and will be deleted as soon as you leave it.">this chat is temporary and will be deleted as soon as you leave it.</span> |
| 4 | - <span data-i18n="Click the button to save it as a file.">Click the button to save it as a file.</span> | |
| 5 | 4 | </div> |
| 6 | 5 | <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"> | |
| 7 | 10 | <i class="fa-solid fa-file-export"></i> |
| 8 | - </div> | |
| 11 | + <span data-i18n="Save">Save</span> | |
| 12 | + </button> | |
| 9 | 13 | </div> |
| @@ -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">|</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}} 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>–</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> | |
| @@ -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> | |
| @@ -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 | +} | |
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | @import url(css/accounts.css); |
| 11 | 11 | @import url(css/tags.css); |
| 12 | 12 | @import url(css/scrollable-button.css); |
| 13 | +@import url(css/welcome.css); | |
| 13 | 14 | |
| 14 | 15 | :root { |
| 15 | 16 | --doc-height: 100%; |
| @@ -6060,12 +6061,12 @@ body:not(.movingUI) .drawer-content.maximized { | ||
| 6060 | 6061 | flex-wrap: nowrap; |
| 6061 | 6062 | justify-content: space-between; |
| 6062 | 6063 | align-items: center; |
| 6063 | 6064 | gap: 10px5px; |
| 6064 | - padding: 0 2px; | |
| 6065 | 6065 | } |
| 6066 | 6066 | |
| 6067 | 6067 | .mes_text div[data-type="assistant_note"]:has(.assistant_note_export) > div:not(.assistant_note_export) { |
| 6068 | 6068 | flex: 1; |
| 6069 | + text-align: left; | |
| 6069 | 6070 | } |
| 6070 | 6071 | |
| 6071 | 6072 | .oneline-dropdown label { |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import path from 'node:path'; |
| 2 | 2 | import fs from 'node:fs'; |
| 3 | 3 | import { promises as fsPromises } from 'node:fs'; |
| 4 | -import readline from 'node:readline'; | |
| 5 | 4 | import { Buffer } from 'node:buffer'; |
| 6 | 5 | |
| 7 | 6 | import express from 'express'; |
| @@ -22,6 +21,7 @@ import { readWorldInfoFile } from './worldinfo.js'; | ||
| 22 | 21 | import { invalidateThumbnail } from './thumbnails.js'; |
| 23 | 22 | import { importRisuSprites } from './sprites.js'; |
| 24 | 23 | import { getUserDirectories } from '../users.js'; |
| 24 | +import { getChatInfo } from './chats.js'; | |
| 25 | 25 | const defaultAvatarPath = './public/img/ai4.png'; |
| 26 | 26 | |
| 27 | 27 | // With 100 MB limit it would take roughly 3000 characters to reach this limit |
| @@ -577,10 +577,10 @@ function charaFormatData(data, directories) { | ||
| 577 | 577 | _.set(char, 'mes_example', data.mes_example || ''); |
| 578 | 578 | |
| 579 | 579 | // Old ST extension fields (for backward compatibility, will be deprecated) |
| 580 | 580 | _.set(char, 'creatorcomment', data.creator_notes || ''); |
| 581 | 581 | _.set(char, 'avatar', 'none'); |
| 582 | 582 | _.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime()); |
| 583 | 583 | _.set(char, 'talkativeness', data.talkativeness || 0.5); |
| 584 | 584 | _.set(char, 'fav', data.fav == 'true'); |
| 585 | 585 | _.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []); |
| 586 | 586 | |
| @@ -604,7 +604,7 @@ function charaFormatData(data, directories) { | ||
| 604 | 604 | _.set(char, 'data.alternate_greetings', getAlternateGreetings(data)); |
| 605 | 605 | |
| 606 | 606 | // ST extension fields to V2 object |
| 607 | 607 | _.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5); |
| 608 | 608 | _.set(char, 'data.extensions.fav', data.fav == 'true'); |
| 609 | 609 | _.set(char, 'data.extensions.world', data.world || ''); |
| 610 | 610 | |
| @@ -943,7 +943,7 @@ router.post('/create', async function (request, response) { | ||
| 943 | 943 | request.body.ch_name = sanitize(request.body.ch_name); |
| 944 | 944 | |
| 945 | 945 | const char = JSON.stringify(charaFormatData(request.body, request.user.directories)); |
| 946 | 946 | const internalName = request.body.file_name || getPngName(request.body.ch_name, request.user.directories); |
| 947 | 947 | const avatarName = `${internalName}.png`; |
| 948 | 948 | const chatsPath = path.join(request.user.directories.chats, internalName); |
| 949 | 949 | |
| @@ -1228,7 +1228,6 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp | ||
| 1228 | 1228 | if (!request.body) return response.sendStatus(400); |
| 1229 | 1229 | |
| 1230 | 1230 | const characterDirectory = (request.body.avatar_url).replace('.png', ''); |
| 1231 | - | |
| 1232 | 1231 | const chatsDirectory = path.join(request.user.directories.chats, characterDirectory); |
| 1233 | 1232 | |
| 1234 | 1233 | if (!fs.existsSync(chatsDirectory)) { |
| @@ -1248,54 +1247,11 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp | ||
| 1248 | 1247 | } |
| 1249 | 1248 | |
| 1250 | 1249 | const jsonFilesPromise = jsonFiles.map((file) => { |
| 1251 | - return new Promise(async (res) => { | |
| 1252 | 1250 | const pathToFile = path.join(request.user.directories.chats, characterDirectory, file); |
| 1253 | - const fileStream = fs.createReadStream(pathToFile); | |
| 1251 | + return getChatInfo(pathToFile); | |
| 1254 | - const stats = fs.statSync(pathToFile); | |
| 1255 | - const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`; | |
| 1256 | - | |
| 1257 | - if (stats.size === 0) { | |
| 1258 | - console.warn(`Found an empty chat file: ${pathToFile}`); | |
| 1259 | - res({}); | |
| 1260 | - return; | |
| 1261 | - } | |
| 1262 | - | |
| 1263 | - const rl = readline.createInterface({ | |
| 1264 | - input: fileStream, | |
| 1265 | - crlfDelay: Infinity, | |
| 1266 | - }); | |
| 1267 | - | |
| 1268 | - let lastLine; | |
| 1269 | - let itemCounter = 0; | |
| 1270 | - rl.on('line', (line) => { | |
| 1271 | - itemCounter++; | |
| 1272 | - lastLine = line; | |
| 1273 | - }); | |
| 1274 | - rl.on('close', () => { | |
| 1275 | - rl.close(); | |
| 1276 | - | |
| 1277 | - if (lastLine) { | |
| 1278 | - const jsonData = tryParse(lastLine); | |
| 1279 | - if (jsonData && (jsonData.name || jsonData.character_name)) { | |
| 1280 | - const chatData = {}; | |
| 1281 | - | |
| 1282 | - chatData['file_name'] = file; | |
| 1283 | - chatData['file_size'] = fileSizeInKB; | |
| 1284 | - chatData['chat_items'] = itemCounter - 1; | |
| 1285 | - chatData['mes'] = jsonData['mes'] || '[The chat is empty]'; | |
| 1286 | - chatData['last_mes'] = jsonData['send_date'] || Date.now(); | |
| 1287 | - | |
| 1288 | - res(chatData); | |
| 1289 | - } else { | |
| 1290 | - console.warn('Found an invalid or corrupted chat file:', pathToFile); | |
| 1291 | - res({}); | |
| 1292 | - } | |
| 1293 | - } | |
| 1294 | - }); | |
| 1295 | - }); | |
| 1296 | 1252 | }); |
| 1297 | 1253 | |
| 1298 | - const chatData = await Promise.all(jsonFilesPromise); | |
| 1254 | + const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); | |
| 1299 | 1255 | const validFiles = chatData.filter(i => i.file_name); |
| 1300 | 1256 | |
| 1301 | 1257 | return response.send(validFiles); |
| @@ -351,6 +351,69 @@ async function checkChatIntegrity(filePath, integritySlug) { | ||
| 351 | 351 | return chatIntegrity === integritySlug; |
| 352 | 352 | } |
| 353 | 353 | |
| 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 | + | |
| 354 | 417 | export const router = express.Router(); |
| 355 | 418 | |
| 356 | 419 | router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { |
| @@ -809,3 +872,79 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response) | ||
| 809 | 872 | return response.status(500).json({ error: 'Search failed' }); |
| 810 | 873 | } |
| 811 | 874 | }); |
| 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 | +}); | |