diff --git a/public/css/welcome.css b/public/css/welcome.css new file mode 100644 index 000000000..ab31ff1a5 --- /dev/null +++ b/public/css/welcome.css @@ -0,0 +1,176 @@ +.welcomePanel { + display: flex; + flex-direction: column; + gap: 5px; + padding: 10px; + width: 100%; +} + +body.bubblechat .welcomePanel { + border-radius: 10px; + background-color: var(--SmartThemeBotMesBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + margin-bottom: 5px; +} + +.welcomePanel .welcomeHeader { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; +} + +.welcomePanel .recentChatsTitle { + flex-grow: 1; + font-size: calc(var(--mainFontSize) * 1.1); + font-weight: 600; +} + +.welcomePanel .welcomeHeaderTitle { + margin: 0; + flex-grow: 1; + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; +} + +.welcomePanel .welcomeHeaderVersionDisplay { + font-size: calc(var(--mainFontSize) * 1.2); + font-weight: 600; +} + +.welcomePanel .welcomeHeaderLogo { + width: 30px; + height: 30px; +} + +.welcomePanel .welcomeShortcuts { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 5px; +} + +.welcomePanel .welcomeShortcuts .welcomeShortcutsSeparator { + margin: 0 2px; + color: var(--SmartThemeBorderColor); + font-size: calc(var(--mainFontSize) * 1.1); +} + +.welcomeRecent .recentChatList { + flex: 1; + display: flex; + flex-direction: column; + width: 100%; + gap: 2px; +} + +.welcomeRecent .welcomePanelLoader { + display: flex; + justify-content: center; + align-items: center; + flex: 1; + width: 100%; + height: 100%; + position: absolute; +} + +.welcomePanel .recentChatList .noRecentChat { + display: flex; + flex-direction: row; + justify-content: center; + align-items: baseline; + gap: 5px; + padding: 10px; +} + +.welcomeRecent .recentChatList .recentChat { + display: flex; + flex-direction: row; + transition: filter 0.2s; + padding: 5px 10px; + border-radius: 10px; + cursor: pointer; + gap: 10px; + border: 1px solid var(--SmartThemeBorderColor); +} + +.welcomeRecent .recentChatList .recentChat .avatar { + flex: 0; +} + +.welcomeRecent .recentChatList .recentChat.selected { + background-color: var(--cobalt30a); +} + +.welcomeRecent .recentChatList .recentChat:hover { + background-color: var(--white30a); +} + +.welcomeRecent .recentChatList .recentChat .recentChatInfo { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + flex-grow: 1; + overflow: hidden; + justify-content: center; +} + +.welcomeRecent .recentChatList .recentChat .chatNameContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + font-size: calc(var(--mainFontSize) * 1); +} + +.welcomeRecent .recentChatList .recentChat .chatNameContainer .chatName { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.welcomeRecent .recentChatList .recentChat .chatMessageContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 5px; + font-size: calc(var(--mainFontSize) * 0.9); +} + +.welcomeRecent .recentChatList .recentChat .chatMessageContainer .chatMessage { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.welcomeRecent .recentChatList .recentChat .chatStats { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: baseline; + gap: 5px; +} + +.welcomeRecent .recentChatList .recentChat .chatStats .counterBlock { + display: flex; + flex-direction: row; + align-items: baseline; + gap: 5px; +} + +.welcomeRecent .recentChatList .recentChat .chatStats .counterBlock::after { + content: "|"; + color: var(--SmartThemeBorderColor); + font-size: calc(var(--mainFontSize) * 0.95); +} + +@media screen and (max-width: 1000px) { + .welcomePanel .welcomeShortcuts a span { + display: none; + } +} diff --git a/public/script.js b/public/script.js index e41741984..8fb3d2dcc 100644 --- a/public/script.js +++ b/public/script.js @@ -282,6 +282,7 @@ import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js'; import { getContext } from './scripts/st-context.js'; import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js'; import { accountStorage } from './scripts/util/AccountStorage.js'; +import { initWelcomeScreen } from './scripts/welcome-screen.js'; // API OBJECT FOR EXTERNAL WIRING globalThis.SillyTavern = { @@ -563,7 +564,7 @@ let chat_create_date = ''; let firstRun = false; let settingsReady = false; let currentVersion = '0.0.0'; -let displayVersion = 'SillyTavern'; +export let displayVersion = 'SillyTavern'; let generatedPromptCache = ''; let generation_started = new Date(); @@ -983,8 +984,6 @@ async function firstLoadInit() { ToolManager.initToolSlashCommands(); await initPresetManager(); await getSystemMessages(); - sendSystemMessage(system_message_types.WELCOME); - sendSystemMessage(system_message_types.WELCOME_PROMPT); await getSettings(); initKeyboard(); initDynamicStyles(); @@ -1009,6 +1008,7 @@ async function firstLoadInit() { initSettingsSearch(); initBulkEdit(); initReasoning(); + initWelcomeScreen(); await initScrapers(); initCustomSelectedSamplers(); addDebugFunctions(); @@ -11176,8 +11176,6 @@ jQuery(async function () { selected_button = 'characters'; $('#rm_button_selected_ch').children('h2').text(''); select_rm_characters(); - sendSystemMessage(system_message_types.WELCOME); - sendSystemMessage(system_message_types.WELCOME_PROMPT); await getClientVersion(); await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId()); } else { diff --git a/public/scripts/templates/welcomePanel.html b/public/scripts/templates/welcomePanel.html new file mode 100644 index 000000000..754a07b74 --- /dev/null +++ b/public/scripts/templates/welcomePanel.html @@ -0,0 +1,71 @@ +
+
+ + {{version}} +
+
+
+ Recent Chats +
+
+ + + Docs + + + + GitHub + + + + Discord + + | + +
+
+
+
+ {{#if empty}} +
+ + No recent chats +
+ {{/if}} + {{#each chats}} + {{#with this}} +
+
+ {{char_name}} +
+
+
+
+ {{char_name}} + + {{chat_name}} +
+ {{date_short}} +
+
+
+ {{mes}} +
+
+
+ + {{chat_items}} +
+ {{file_size}} +
+
+
+
+ {{/with}} + {{/each}} +
+
+
diff --git a/public/scripts/welcome-screen.js b/public/scripts/welcome-screen.js new file mode 100644 index 000000000..09ecee035 --- /dev/null +++ b/public/scripts/welcome-screen.js @@ -0,0 +1,135 @@ +import { + characters, + displayVersion, + event_types, + eventSource, + getCurrentChatId, + getRequestHeaders, + getThumbnailUrl, + openCharacterChat, + selectCharacterById, + sendSystemMessage, + system_message_types, +} from '../script.js'; +import { t } from './i18n.js'; +import { renderTemplateAsync } from './templates.js'; +import { timestampToMoment } from './utils.js'; + +export async function openWelcomeScreen() { + const currentChatId = getCurrentChatId(); + if (currentChatId !== undefined) { + return; + } + + await sendWelcomePanel(); + sendSystemMessage(system_message_types.WELCOME_PROMPT); +} + +async function sendWelcomePanel() { + try { + const chatElement = document.getElementById('chat'); + if (!chatElement) { + console.error('Chat element not found'); + return; + } + const chats = await getRecentChats(); + const templateData = { + chats, + empty: !chats.length , + version: displayVersion, + }; + const template = await renderTemplateAsync('welcomePanel', templateData); + const fragment = document.createRange().createContextualFragment(template); + fragment.querySelectorAll('.recentChat').forEach((item) => { + item.addEventListener('click', () => { + const avatarId = item.getAttribute('data-avatar'); + const fileName = item.getAttribute('data-file'); + if (avatarId && fileName) { + void openRecentChat(avatarId, fileName); + } + }); + }); + fragment.querySelector('button.openTemporaryChat').addEventListener('click', () => { + toastr.info('This button does nothing at the moment. Try again later.'); + }); + chatElement.append(fragment.firstChild); + } catch (error) { + console.error('Welcome screen error:', error); + } +} + +/** + * Opens a recent chat. + * @param {string} avatarId Avatar file name + * @param {string} fileName Chat file name + */ +async function openRecentChat(avatarId, fileName) { + const characterId = characters.findIndex(x => x.avatar === avatarId); + if (characterId === -1) { + console.error(`Character not found for avatar ID: ${avatarId}`); + return; + } + + try { + await selectCharacterById(characterId); + await openCharacterChat(fileName); + } catch (error) { + console.error('Error opening recent chat:', error); + toastr.error(t`Failed to open recent chat. See console for details.`); + } +} + +/** + * Gets the list of recent chats from the server. + * @returns {Promise} List of recent chats + * + * @typedef {object} RecentChat + * @property {string} file_name Name of the chat file + * @property {string} chat_name Name of the chat (without extension) + * @property {string} file_size Size of the chat file + * @property {number} chat_items Number of items in the chat + * @property {string} mes Last message content + * @property {number} last_mes Timestamp of the last message + * @property {string} avatar Avatar URL + * @property {string} char_thumbnail Thumbnail URL + * @property {string} char_name Character name + * @property {string} date_short Date in short format + * @property {string} date_long Date in long format + */ +async function getRecentChats() { + const response = await fetch('/api/characters/recent', { + method: 'POST', + headers: getRequestHeaders(), + }); + if (!response.ok) { + throw new Error('Failed to fetch recent chats'); + } + + /** @type {RecentChat[]} */ + const data = await response.json(); + + data.sort((a, b) => b.last_mes - a.last_mes).forEach((chat, index) => { + const character = characters.find(x => x.avatar === chat.avatar); + if (!character) { + console.warn(`Character not found for chat: ${chat.file_name}`); + data.splice(index, 1); + return; + } + + const chatTimestamp = timestampToMoment(chat.last_mes); + chat.char_name = character.name; + chat.date_short = chatTimestamp.format('l'); + chat.date_long = chatTimestamp.format('LL LT'); + chat.chat_name = chat.file_name.replace('.jsonl', ''); + chat.char_thumbnail = getThumbnailUrl('avatar', character.avatar); + }); + + return data; +} + +export function initWelcomeScreen() { + const events = [event_types.CHAT_CHANGED, event_types.APP_READY]; + for (const event of events) { + eventSource.makeFirst(event, openWelcomeScreen); + } +} diff --git a/public/style.css b/public/style.css index 8e8ec8b7a..70a193092 100644 --- a/public/style.css +++ b/public/style.css @@ -10,6 +10,7 @@ @import url(css/accounts.css); @import url(css/tags.css); @import url(css/scrollable-button.css); +@import url(css/welcome.css); :root { --doc-height: 100%; diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js index a4a307546..6c5757cbc 100644 --- a/src/endpoints/characters.js +++ b/src/endpoints/characters.js @@ -934,6 +934,69 @@ async function importFromPng(uploadPath, { request }, preservedFileName) { return ''; } +/** + * @typedef {Object} ChatInfo + * @property {string} [file_name] - The name of the chat file + * @property {string} [file_size] - The size of the chat file + * @property {number} [chat_items] - The number of chat items in the file + * @property {string} [mes] - The last message in the chat + * @property {number} [last_mes] - The timestamp of the last message + */ + +/** + * Reads the information from a chat file. + * @param {string} pathToFile + * @param {object} additionalData + * @returns {Promise} + */ +async function getChatInfo(pathToFile, additionalData = {}) { + return new Promise(async (res) => { + const fileStream = fs.createReadStream(pathToFile); + const stats = fs.statSync(pathToFile); + const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`; + + if (stats.size === 0) { + console.warn(`Found an empty chat file: ${pathToFile}`); + res({}); + return; + } + + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + let lastLine; + let itemCounter = 0; + rl.on('line', (line) => { + itemCounter++; + lastLine = line; + }); + rl.on('close', () => { + rl.close(); + + if (lastLine) { + const jsonData = tryParse(lastLine); + if (jsonData && (jsonData.name || jsonData.character_name)) { + const chatData = {}; + + chatData['file_name'] = path.parse(pathToFile).base; + chatData['file_size'] = fileSizeInKB; + chatData['chat_items'] = itemCounter - 1; + chatData['mes'] = jsonData['mes'] || '[The chat is empty]'; + chatData['last_mes'] = jsonData['send_date'] || Date.now(); + Object.assign(chatData, additionalData); + + res(chatData); + } else { + console.warn('Found an invalid or corrupted chat file:', pathToFile); + res({}); + } + } + }); + }); +} + export const router = express.Router(); router.post('/create', async function (request, response) { @@ -1223,12 +1286,52 @@ router.post('/get', validateAvatarUrlMiddleware, async function (request, respon } }); +router.post('/recent', async function (request, response) { + try { + /** @type {{pngFile: string, filePath: string, mtime: number}[]} */ + const allChatFiles = []; + + const pngFiles = fs + .readdirSync(request.user.directories.characters, { withFileTypes: true }) + .filter(dirent => dirent.isFile() && dirent.name.endsWith('.png')) + .map(dirent => dirent.name); + + for (const pngFile of pngFiles) { + const chatsDirectory = pngFile.replace('.png', ''); + const pathToChats = path.join(request.user.directories.chats, chatsDirectory); + if (fs.existsSync(pathToChats) && fs.statSync(pathToChats).isDirectory()) { + const chatFiles = fs.readdirSync(pathToChats); + const chatFilesWithDate = chatFiles + .filter(file => file.endsWith('.jsonl')) + .map(file => { + const filePath = path.join(pathToChats, file); + const stats = fs.statSync(filePath); + return { pngFile, filePath, mtime: stats.mtimeMs }; + }); + allChatFiles.push(...chatFilesWithDate); + } + } + + const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, 5); + const jsonFilesPromise = recentChats.map((file) => { + return getChatInfo(file.filePath, { avatar: file.pngFile }); + }); + + const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); + const validFiles = chatData.filter(i => i.file_name); + + return response.send(validFiles); + } catch (error) { + console.error(error); + return response.sendStatus(500); + } +}); + router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) { try { if (!request.body) return response.sendStatus(400); const characterDirectory = (request.body.avatar_url).replace('.png', ''); - const chatsDirectory = path.join(request.user.directories.chats, characterDirectory); if (!fs.existsSync(chatsDirectory)) { @@ -1248,54 +1351,11 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp } const jsonFilesPromise = jsonFiles.map((file) => { - return new Promise(async (res) => { - const pathToFile = path.join(request.user.directories.chats, characterDirectory, file); - const fileStream = fs.createReadStream(pathToFile); - const stats = fs.statSync(pathToFile); - const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`; - - if (stats.size === 0) { - console.warn(`Found an empty chat file: ${pathToFile}`); - res({}); - return; - } - - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - let lastLine; - let itemCounter = 0; - rl.on('line', (line) => { - itemCounter++; - lastLine = line; - }); - rl.on('close', () => { - rl.close(); - - if (lastLine) { - const jsonData = tryParse(lastLine); - if (jsonData && (jsonData.name || jsonData.character_name)) { - const chatData = {}; - - chatData['file_name'] = file; - chatData['file_size'] = fileSizeInKB; - chatData['chat_items'] = itemCounter - 1; - chatData['mes'] = jsonData['mes'] || '[The chat is empty]'; - chatData['last_mes'] = jsonData['send_date'] || Date.now(); - - res(chatData); - } else { - console.warn('Found an invalid or corrupted chat file:', pathToFile); - res({}); - } - } - }); - }); + const pathToFile = path.join(request.user.directories.chats, characterDirectory, file); + return getChatInfo(pathToFile); }); - const chatData = await Promise.all(jsonFilesPromise); + const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); const validFiles = chatData.filter(i => i.file_name); return response.send(validFiles);