[wip] Welcome screen prototype

e975d37436eb3a47cbd2690d047cd08286dd59b5

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

6 files changed, +492 -51Showing whitespace changes
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
9body.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';
282import { getContext } from './scripts/st-context.js';282import { getContext } from './scripts/st-context.js';
283import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';283import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
284import { accountStorage } from './scripts/util/AccountStorage.js';284import { accountStorage } from './scripts/util/AccountStorage.js';
285import { initWelcomeScreen } from './scripts/welcome-screen.js';
285286
286// API OBJECT FOR EXTERNAL WIRING287// API OBJECT FOR EXTERNAL WIRING
287globalThis.SillyTavern = {288globalThis.SillyTavern = {
@@ -563,7 +564,7 @@ let chat_create_date = '';
563let firstRun = false;564let firstRun = false;
564let settingsReady = false;565let settingsReady = false;
565let currentVersion = '0.0.0';566let currentVersion = '0.0.0';
566let displayVersion = 'SillyTavern';567export let displayVersion = 'SillyTavern';
567568
568let generatedPromptCache = '';569let generatedPromptCache = '';
569let generation_started = new Date();570let generation_started = new Date();
@@ -983,8 +984,6 @@ async function firstLoadInit() {
983 ToolManager.initToolSlashCommands();984 ToolManager.initToolSlashCommands();
984 await initPresetManager();985 await initPresetManager();
985 await getSystemMessages();986 await getSystemMessages();
986 sendSystemMessage(system_message_types.WELCOME);
987 sendSystemMessage(system_message_types.WELCOME_PROMPT);
988 await getSettings();987 await getSettings();
989 initKeyboard();988 initKeyboard();
990 initDynamicStyles();989 initDynamicStyles();
@@ -1009,6 +1008,7 @@ async function firstLoadInit() {
1009 initSettingsSearch();1008 initSettingsSearch();
1010 initBulkEdit();1009 initBulkEdit();
1011 initReasoning();1010 initReasoning();
1011 initWelcomeScreen();
1012 await initScrapers();1012 await initScrapers();
1013 initCustomSelectedSamplers();1013 initCustomSelectedSamplers();
1014 addDebugFunctions();1014 addDebugFunctions();
@@ -11176,8 +11176,6 @@ jQuery(async function () {
11176 selected_button = 'characters';11176 selected_button = 'characters';
11177 $('#rm_button_selected_ch').children('h2').text('');11177 $('#rm_button_selected_ch').children('h2').text('');
11178 select_rm_characters();11178 select_rm_characters();
11179 sendSystemMessage(system_message_types.WELCOME);
11180 sendSystemMessage(system_message_types.WELCOME_PROMPT);
11181 await getClientVersion();11179 await getClientVersion();
11182 await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());11180 await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
11183 } else {11181 } 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 @@
1import {
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';
14import { t } from './i18n.js';
15import { renderTemplateAsync } from './templates.js';
16import { timestampToMoment } from './utils.js';
17
18export 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
28async 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 */
66async 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 */
99async 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
130export 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 @@
10@import url(css/accounts.css);10@import url(css/accounts.css);
11@import url(css/tags.css);11@import url(css/tags.css);
12@import url(css/scrollable-button.css);12@import url(css/scrollable-button.css);
13@import url(css/welcome.css);
1314
14:root {15:root {
15 --doc-height: 100%;16 --doc-height: 100%;
src/endpoints/characters.js+106 -46
@@ -934,6 +934,69 @@ async function importFromPng(uploadPath, { request }, preservedFileName) {
934 return '';934 return '';
935}935}
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 */
952async 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
937export const router = express.Router();1000export const router = express.Router();
9381001
939router.post('/create', async function (request, response) {1002router.post('/create', async function (request, response) {
@@ -1223,12 +1286,52 @@ router.post('/get', validateAvatarUrlMiddleware, async function (request, respon
1223 }1286 }
1224});1287});
12251288
1289router.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
1226router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) {1330router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) {
1227 try {1331 try {
1228 if (!request.body) return response.sendStatus(400);1332 if (!request.body) return response.sendStatus(400);
12291333
1230 const characterDirectory = (request.body.avatar_url).replace('.png', '');1334 const characterDirectory = (request.body.avatar_url).replace('.png', '');
1231
1232 const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);1335 const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
12331336
1234 if (!fs.existsSync(chatsDirectory)) {1337 if (!fs.existsSync(chatsDirectory)) {
@@ -1248,54 +1351,11 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
1248 }1351 }
12491352
1250 const jsonFilesPromise = jsonFiles.map((file) => {1353 const jsonFilesPromise = jsonFiles.map((file) => {
1251 return new Promise(async (res) => {
1252 const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);1354 const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
1253 const fileStream = fs.createReadStream(pathToFile);1355 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 });1356 });
12971357
1298 const chatData = await Promise.all(jsonFilesPromise);1358 const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
1299 const validFiles = chatData.filter(i => i.file_name);1359 const validFiles = chatData.filter(i => i.file_name);
13001360
1301 return response.send(validFiles);1361 return response.send(validFiles);