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 @@
+
+
+
+
+
+ {{#if empty}}
+
+
+ No recent chats
+
+ {{/if}}
+ {{#each chats}}
+ {{#with this}}
+
+
+

+
+
+
+
+ {{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);