[wip] Welcome screen prototype

e975d37436eb3a47cbd2690d047cd08286dd59b5

Cohee <18619528+Cohee1207@users.noreply.github.com>

6 files changed, +493 -52Ignore whitespace
public/css/welcome.css+176 -0
@@ -0,0 +1,176 @@
1+.welcomePanel {
2+ display: flex;
3+ flex-direction: column;
4+ gap: 5px;
5+ padding: 10px;
6+ width: 100%;
7+}
8+
9+body.bubblechat .welcomePanel {
10+ border-radius: 10px;
11+ background-color: var(--SmartThemeBotMesBlurTintColor);
12+ border: 1px solid var(--SmartThemeBorderColor);
13+ margin-bottom: 5px;
14+}
15+
16+.welcomePanel .welcomeHeader {
17+ display: flex;
18+ flex-direction: row;
19+ align-items: center;
20+ justify-content: flex-end;
21+}
22+
23+.welcomePanel .recentChatsTitle {
24+ flex-grow: 1;
25+ font-size: calc(var(--mainFontSize) * 1.1);
26+ font-weight: 600;
27+}
28+
29+.welcomePanel .welcomeHeaderTitle {
30+ margin: 0;
31+ flex-grow: 1;
32+ display: flex;
33+ flex-direction: row;
34+ align-items: center;
35+ gap: 10px;
36+}
37+
38+.welcomePanel .welcomeHeaderVersionDisplay {
39+ font-size: calc(var(--mainFontSize) * 1.2);
40+ font-weight: 600;
41+}
42+
43+.welcomePanel .welcomeHeaderLogo {
44+ width: 30px;
45+ height: 30px;
46+}
47+
48+.welcomePanel .welcomeShortcuts {
49+ display: flex;
50+ flex-direction: row;
51+ flex-wrap: wrap;
52+ align-items: center;
53+ justify-content: center;
54+ gap: 5px;
55+}
56+
57+.welcomePanel .welcomeShortcuts .welcomeShortcutsSeparator {
58+ margin: 0 2px;
59+ color: var(--SmartThemeBorderColor);
60+ font-size: calc(var(--mainFontSize) * 1.1);
61+}
62+
63+.welcomeRecent .recentChatList {
64+ flex: 1;
65+ display: flex;
66+ flex-direction: column;
67+ width: 100%;
68+ gap: 2px;
69+}
70+
71+.welcomeRecent .welcomePanelLoader {
72+ display: flex;
73+ justify-content: center;
74+ align-items: center;
75+ flex: 1;
76+ width: 100%;
77+ height: 100%;
78+ position: absolute;
79+}
80+
81+.welcomePanel .recentChatList .noRecentChat {
82+ display: flex;
83+ flex-direction: row;
84+ justify-content: center;
85+ align-items: baseline;
86+ gap: 5px;
87+ padding: 10px;
88+}
89+
90+.welcomeRecent .recentChatList .recentChat {
91+ display: flex;
92+ flex-direction: row;
93+ transition: filter 0.2s;
94+ padding: 5px 10px;
95+ border-radius: 10px;
96+ cursor: pointer;
97+ gap: 10px;
98+ border: 1px solid var(--SmartThemeBorderColor);
99+}
100+
101+.welcomeRecent .recentChatList .recentChat .avatar {
102+ flex: 0;
103+}
104+
105+.welcomeRecent .recentChatList .recentChat.selected {
106+ background-color: var(--cobalt30a);
107+}
108+
109+.welcomeRecent .recentChatList .recentChat:hover {
110+ background-color: var(--white30a);
111+}
112+
113+.welcomeRecent .recentChatList .recentChat .recentChatInfo {
114+ display: flex;
115+ flex-direction: column;
116+ flex-wrap: nowrap;
117+ flex-grow: 1;
118+ overflow: hidden;
119+ justify-content: center;
120+}
121+
122+.welcomeRecent .recentChatList .recentChat .chatNameContainer {
123+ display: flex;
124+ flex-direction: row;
125+ justify-content: space-between;
126+ align-items: baseline;
127+ font-size: calc(var(--mainFontSize) * 1);
128+}
129+
130+.welcomeRecent .recentChatList .recentChat .chatNameContainer .chatName {
131+ white-space: nowrap;
132+ text-overflow: ellipsis;
133+ overflow: hidden;
134+}
135+
136+.welcomeRecent .recentChatList .recentChat .chatMessageContainer {
137+ display: flex;
138+ flex-direction: row;
139+ align-items: center;
140+ justify-content: space-between;
141+ gap: 5px;
142+ font-size: calc(var(--mainFontSize) * 0.9);
143+}
144+
145+.welcomeRecent .recentChatList .recentChat .chatMessageContainer .chatMessage {
146+ white-space: nowrap;
147+ text-overflow: ellipsis;
148+ overflow: hidden;
149+}
150+
151+.welcomeRecent .recentChatList .recentChat .chatStats {
152+ display: flex;
153+ flex-direction: row;
154+ justify-content: flex-end;
155+ align-items: baseline;
156+ gap: 5px;
157+}
158+
159+.welcomeRecent .recentChatList .recentChat .chatStats .counterBlock {
160+ display: flex;
161+ flex-direction: row;
162+ align-items: baseline;
163+ gap: 5px;
164+}
165+
166+.welcomeRecent .recentChatList .recentChat .chatStats .counterBlock::after {
167+ content: "|";
168+ color: var(--SmartThemeBorderColor);
169+ font-size: calc(var(--mainFontSize) * 0.95);
170+}
171+
172+@media screen and (max-width: 1000px) {
173+ .welcomePanel .welcomeShortcuts a span {
174+ display: none;
175+ }
176+}
public/script.js+3 -5
@@ -282,6 +282,7 @@ import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js';
282282import { getContext } from './scripts/st-context.js';
283283import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
284284import { accountStorage } from './scripts/util/AccountStorage.js';
285+import { initWelcomeScreen } from './scripts/welcome-screen.js';
285286
286287// API OBJECT FOR EXTERNAL WIRING
287288globalThis.SillyTavern = {
@@ -563,7 +564,7 @@ let chat_create_date = '';
563564let firstRun = false;
564565let settingsReady = false;
565566let currentVersion = '0.0.0';
566567export let displayVersion = 'SillyTavern';
567568
568569let generatedPromptCache = '';
569570let generation_started = new Date();
@@ -983,8 +984,6 @@ async function firstLoadInit() {
983984 ToolManager.initToolSlashCommands();
984985 await initPresetManager();
985986 await getSystemMessages();
986- sendSystemMessage(system_message_types.WELCOME);
987- sendSystemMessage(system_message_types.WELCOME_PROMPT);
988987 await getSettings();
989988 initKeyboard();
990989 initDynamicStyles();
@@ -1009,6 +1008,7 @@ async function firstLoadInit() {
10091008 initSettingsSearch();
10101009 initBulkEdit();
10111010 initReasoning();
1011+ initWelcomeScreen();
10121012 await initScrapers();
10131013 initCustomSelectedSamplers();
10141014 addDebugFunctions();
@@ -11176,8 +11176,6 @@ jQuery(async function () {
1117611176 selected_button = 'characters';
1117711177 $('#rm_button_selected_ch').children('h2').text('');
1117811178 select_rm_characters();
11179- sendSystemMessage(system_message_types.WELCOME);
11180- sendSystemMessage(system_message_types.WELCOME_PROMPT);
1118111179 await getClientVersion();
1118211180 await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
1118311181 } else {
public/scripts/templates/welcomePanel.html+71 -0
@@ -0,0 +1,71 @@
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>
6+ <div class="welcomeHeader">
7+ <div class="recentChatsTitle" data-i18n="Recent Chats">
8+ Recent Chats
9+ </div>
10+ <div class="welcomeShortcuts">
11+ <a class="menu_button menu_button_icon" target="_blank" href="https://docs.sillytavern.app/">
12+ <i class="fa-solid fa-question-circle"></i>
13+ <span data-i18n="Docs">Docs</span>
14+ </a>
15+ <a class="menu_button menu_button_icon" target="_blank" href="https://github.com/SillyTavern/SillyTavern">
16+ <i class="fa-brands fa-github"></i>
17+ <span data-i18n="GitHub">GitHub</span>
18+ </a>
19+ <a class="menu_button menu_button_icon" target="_blank" href="https://discord.gg/sillytavern">
20+ <i class="fa-brands fa-discord"></i>
21+ <span data-i18n="Discord">Discord</span>
22+ </a>
23+ <span class="welcomeShortcutsSeparator">&vert;</span>
24+ <button class="openTemporaryChat menu_button menu_button_icon">
25+ <i class="fa-solid fa-comment-dots"></i>
26+ <span data-i18n="Temporary Chat">Temporary Chat</span>
27+ </button>
28+ </div>
29+ </div>
30+ <div class="welcomeRecent">
31+ <div class="recentChatList">
32+ {{#if empty}}
33+ <div class="noRecentChat">
34+ <i class="fa-solid fa-comment-dots"></i>
35+ <span data-i18n="No recent chats">No recent chats</span>
36+ </div>
37+ {{/if}}
38+ {{#each chats}}
39+ {{#with this}}
40+ <div class="recentChat" data-file="{{chat_name}}" data-avatar="{{avatar}}">
41+ <div class="avatar" title="{{char_name}}">
42+ <img src="{{char_thumbnail}}" alt="{{char_name}}">
43+ </div>
44+ <div class="recentChatInfo">
45+ <div class="chatNameContainer">
46+ <div class="chatName" title="{{file_name}}">
47+ <strong class="characterName">{{char_name}}</strong>
48+ <span>&ndash;</span>
49+ <span>{{chat_name}}</span>
50+ </div>
51+ <small class="chatDate" title="{{date_full}}">{{date_short}}</small>
52+ </div>
53+ <div class="chatMessageContainer">
54+ <div class="chatMessage" title="{{mes}}">
55+ {{mes}}
56+ </div>
57+ <div class="chatStats">
58+ <div class="counterBlock">
59+ <i class="fa-solid fa-comment fa-xs"></i>
60+ <small>{{chat_items}}</small>
61+ </div>
62+ <small class="fileSize">{{file_size}}</small>
63+ </div>
64+ </div>
65+ </div>
66+ </div>
67+ {{/with}}
68+ {{/each}}
69+ </div>
70+ </div>
71+</div>
public/scripts/welcome-screen.js+135 -0
@@ -0,0 +1,135 @@
1+import {
2+ characters,
3+ displayVersion,
4+ event_types,
5+ eventSource,
6+ getCurrentChatId,
7+ getRequestHeaders,
8+ getThumbnailUrl,
9+ openCharacterChat,
10+ selectCharacterById,
11+ sendSystemMessage,
12+ system_message_types,
13+} from '../script.js';
14+import { t } from './i18n.js';
15+import { renderTemplateAsync } from './templates.js';
16+import { timestampToMoment } from './utils.js';
17+
18+export async function openWelcomeScreen() {
19+ const currentChatId = getCurrentChatId();
20+ if (currentChatId !== undefined) {
21+ return;
22+ }
23+
24+ await sendWelcomePanel();
25+ sendSystemMessage(system_message_types.WELCOME_PROMPT);
26+}
27+
28+async function sendWelcomePanel() {
29+ try {
30+ const chatElement = document.getElementById('chat');
31+ if (!chatElement) {
32+ console.error('Chat element not found');
33+ return;
34+ }
35+ const chats = await getRecentChats();
36+ const templateData = {
37+ chats,
38+ empty: !chats.length ,
39+ version: displayVersion,
40+ };
41+ const template = await renderTemplateAsync('welcomePanel', templateData);
42+ const fragment = document.createRange().createContextualFragment(template);
43+ fragment.querySelectorAll('.recentChat').forEach((item) => {
44+ item.addEventListener('click', () => {
45+ const avatarId = item.getAttribute('data-avatar');
46+ const fileName = item.getAttribute('data-file');
47+ if (avatarId && fileName) {
48+ void openRecentChat(avatarId, fileName);
49+ }
50+ });
51+ });
52+ fragment.querySelector('button.openTemporaryChat').addEventListener('click', () => {
53+ toastr.info('This button does nothing at the moment. Try again later.');
54+ });
55+ chatElement.append(fragment.firstChild);
56+ } catch (error) {
57+ console.error('Welcome screen error:', error);
58+ }
59+}
60+
61+/**
62+ * Opens a recent chat.
63+ * @param {string} avatarId Avatar file name
64+ * @param {string} fileName Chat file name
65+ */
66+async function openRecentChat(avatarId, fileName) {
67+ const characterId = characters.findIndex(x => x.avatar === avatarId);
68+ if (characterId === -1) {
69+ console.error(`Character not found for avatar ID: ${avatarId}`);
70+ return;
71+ }
72+
73+ try {
74+ await selectCharacterById(characterId);
75+ await openCharacterChat(fileName);
76+ } catch (error) {
77+ console.error('Error opening recent chat:', error);
78+ toastr.error(t`Failed to open recent chat. See console for details.`);
79+ }
80+}
81+
82+/**
83+ * Gets the list of recent chats from the server.
84+ * @returns {Promise<RecentChat[]>} List of recent chats
85+ *
86+ * @typedef {object} RecentChat
87+ * @property {string} file_name Name of the chat file
88+ * @property {string} chat_name Name of the chat (without extension)
89+ * @property {string} file_size Size of the chat file
90+ * @property {number} chat_items Number of items in the chat
91+ * @property {string} mes Last message content
92+ * @property {number} last_mes Timestamp of the last message
93+ * @property {string} avatar Avatar URL
94+ * @property {string} char_thumbnail Thumbnail URL
95+ * @property {string} char_name Character name
96+ * @property {string} date_short Date in short format
97+ * @property {string} date_long Date in long format
98+ */
99+async function getRecentChats() {
100+ const response = await fetch('/api/characters/recent', {
101+ method: 'POST',
102+ headers: getRequestHeaders(),
103+ });
104+ if (!response.ok) {
105+ throw new Error('Failed to fetch recent chats');
106+ }
107+
108+ /** @type {RecentChat[]} */
109+ const data = await response.json();
110+
111+ data.sort((a, b) => b.last_mes - a.last_mes).forEach((chat, index) => {
112+ const character = characters.find(x => x.avatar === chat.avatar);
113+ if (!character) {
114+ console.warn(`Character not found for chat: ${chat.file_name}`);
115+ data.splice(index, 1);
116+ return;
117+ }
118+
119+ const chatTimestamp = timestampToMoment(chat.last_mes);
120+ chat.char_name = character.name;
121+ chat.date_short = chatTimestamp.format('l');
122+ chat.date_long = chatTimestamp.format('LL LT');
123+ chat.chat_name = chat.file_name.replace('.jsonl', '');
124+ chat.char_thumbnail = getThumbnailUrl('avatar', character.avatar);
125+ });
126+
127+ return data;
128+}
129+
130+export function initWelcomeScreen() {
131+ const events = [event_types.CHAT_CHANGED, event_types.APP_READY];
132+ for (const event of events) {
133+ eventSource.makeFirst(event, openWelcomeScreen);
134+ }
135+}
public/style.css+1 -0
@@ -10,6 +10,7 @@
1010@import url(css/accounts.css);
1111@import url(css/tags.css);
1212@import url(css/scrollable-button.css);
13+@import url(css/welcome.css);
1314
1415:root {
1516 --doc-height: 100%;
src/endpoints/characters.js+107 -47
@@ -934,6 +934,69 @@ async function importFromPng(uploadPath, { request }, preservedFileName) {
934934 return '';
935935}
936936
937+/**
938+ * @typedef {Object} ChatInfo
939+ * @property {string} [file_name] - The name of the chat file
940+ * @property {string} [file_size] - The size of the chat file
941+ * @property {number} [chat_items] - The number of chat items in the file
942+ * @property {string} [mes] - The last message in the chat
943+ * @property {number} [last_mes] - The timestamp of the last message
944+ */
945+
946+/**
947+ * Reads the information from a chat file.
948+ * @param {string} pathToFile
949+ * @param {object} additionalData
950+ * @returns {Promise<ChatInfo>}
951+ */
952+async function getChatInfo(pathToFile, additionalData = {}) {
953+ return new Promise(async (res) => {
954+ const fileStream = fs.createReadStream(pathToFile);
955+ const stats = fs.statSync(pathToFile);
956+ const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`;
957+
958+ if (stats.size === 0) {
959+ console.warn(`Found an empty chat file: ${pathToFile}`);
960+ res({});
961+ return;
962+ }
963+
964+ const rl = readline.createInterface({
965+ input: fileStream,
966+ crlfDelay: Infinity,
967+ });
968+
969+ let lastLine;
970+ let itemCounter = 0;
971+ rl.on('line', (line) => {
972+ itemCounter++;
973+ lastLine = line;
974+ });
975+ rl.on('close', () => {
976+ rl.close();
977+
978+ if (lastLine) {
979+ const jsonData = tryParse(lastLine);
980+ if (jsonData && (jsonData.name || jsonData.character_name)) {
981+ const chatData = {};
982+
983+ chatData['file_name'] = path.parse(pathToFile).base;
984+ chatData['file_size'] = fileSizeInKB;
985+ chatData['chat_items'] = itemCounter - 1;
986+ chatData['mes'] = jsonData['mes'] || '[The chat is empty]';
987+ chatData['last_mes'] = jsonData['send_date'] || Date.now();
988+ Object.assign(chatData, additionalData);
989+
990+ res(chatData);
991+ } else {
992+ console.warn('Found an invalid or corrupted chat file:', pathToFile);
993+ res({});
994+ }
995+ }
996+ });
997+ });
998+}
999+
9371000export const router = express.Router();
9381001
9391002router.post('/create', async function (request, response) {
@@ -1223,12 +1286,52 @@ router.post('/get', validateAvatarUrlMiddleware, async function (request, respon
12231286 }
12241287});
12251288
1289+router.post('/recent', async function (request, response) {
1290+ try {
1291+ /** @type {{pngFile: string, filePath: string, mtime: number}[]} */
1292+ const allChatFiles = [];
1293+
1294+ const pngFiles = fs
1295+ .readdirSync(request.user.directories.characters, { withFileTypes: true })
1296+ .filter(dirent => dirent.isFile() && dirent.name.endsWith('.png'))
1297+ .map(dirent => dirent.name);
1298+
1299+ for (const pngFile of pngFiles) {
1300+ const chatsDirectory = pngFile.replace('.png', '');
1301+ const pathToChats = path.join(request.user.directories.chats, chatsDirectory);
1302+ if (fs.existsSync(pathToChats) && fs.statSync(pathToChats).isDirectory()) {
1303+ const chatFiles = fs.readdirSync(pathToChats);
1304+ const chatFilesWithDate = chatFiles
1305+ .filter(file => file.endsWith('.jsonl'))
1306+ .map(file => {
1307+ const filePath = path.join(pathToChats, file);
1308+ const stats = fs.statSync(filePath);
1309+ return { pngFile, filePath, mtime: stats.mtimeMs };
1310+ });
1311+ allChatFiles.push(...chatFilesWithDate);
1312+ }
1313+ }
1314+
1315+ const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, 5);
1316+ const jsonFilesPromise = recentChats.map((file) => {
1317+ return getChatInfo(file.filePath, { avatar: file.pngFile });
1318+ });
1319+
1320+ const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
1321+ const validFiles = chatData.filter(i => i.file_name);
1322+
1323+ return response.send(validFiles);
1324+ } catch (error) {
1325+ console.error(error);
1326+ return response.sendStatus(500);
1327+ }
1328+});
1329+
12261330router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) {
12271331 try {
12281332 if (!request.body) return response.sendStatus(400);
12291333
12301334 const characterDirectory = (request.body.avatar_url).replace('.png', '');
1231-
12321335 const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
12331336
12341337 if (!fs.existsSync(chatsDirectory)) {
@@ -1248,54 +1351,11 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
12481351 }
12491352
12501353 const jsonFilesPromise = jsonFiles.map((file) => {
1251- return new Promise(async (res) => {
1354+ const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1252- const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1355+ return getChatInfo(pathToFile);
1253- const fileStream = fs.createReadStream(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- });
12961356 });
12971357
1298- const chatData = await Promise.all(jsonFilesPromise);
1358+ const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
12991359 const validFiles = chatData.filter(i => i.file_name);
13001360
13011361 return response.send(validFiles);