mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Merge pull request #3990 from SillyTavern/welcome-screen
New welcome screen + quick chat functionality
This commit is contained in:
209
public/css/welcome.css
Normal file
209
public/css/welcome.css
Normal file
@@ -0,0 +1,209 @@
|
||||
#chat .mes[type="assistant_message"] .mes_button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.welcomePanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.welcomePanel:has(.showMoreChats) {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.welcomePanel.recentHidden .welcomeRecent,
|
||||
.welcomePanel.recentHidden .recentChatsTitle,
|
||||
.welcomePanel.recentHidden .hideRecentChats,
|
||||
.welcomePanel:not(.recentHidden) .showRecentChats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
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.15);
|
||||
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.3);
|
||||
font-weight: 600;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
}
|
||||
|
||||
.welcomeRecent .recentChatList .recentChat .avatar {
|
||||
flex: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.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.85);
|
||||
}
|
||||
|
||||
.welcomeRecent .recentChatList .recentChat .chatMessageContainer .chatMessage {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.big-avatars .welcomeRecent .recentChatList .recentChat .chatMessageContainer .chatMessage {
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
}
|
||||
|
||||
.welcomeRecent .recentChatList .recentChat .chatStats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
align-self: flex-start;
|
||||
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);
|
||||
}
|
||||
|
||||
.welcomeRecent .recentChatList .recentChat.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.welcomeRecent .recentChatList .showMoreChats {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.welcomeRecent .recentChatList .showMoreChats.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.welcomePanel .welcomeShortcuts a span {
|
||||
display: none;
|
||||
}
|
||||
}
|
@@ -5362,6 +5362,9 @@
|
||||
<option id="import_tags" data-i18n="Import Tags">
|
||||
Import Tags
|
||||
</option>
|
||||
<option id="set_as_assistant" data-i18n="Set / Unset as Welcome Page Assistant">
|
||||
Set / Unset as Welcome Page Assistant
|
||||
</option>
|
||||
<!--<option id="dupe_button">
|
||||
Duplicate
|
||||
</option>
|
||||
@@ -6383,6 +6386,9 @@
|
||||
<div class="wide100p character_name_block">
|
||||
<span class="ch_name"></span>
|
||||
<small class="ch_additional_info ch_add_placeholder">+++</small>
|
||||
<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.">
|
||||
<i class="fa-solid fa-sm fa-user-graduate"></i>
|
||||
</small>
|
||||
<small class="ch_additional_info character_version"></small>
|
||||
<small class="ch_additional_info ch_avatar_url"></small>
|
||||
</div>
|
||||
|
@@ -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, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
|
||||
|
||||
// API OBJECT FOR EXTERNAL WIRING
|
||||
globalThis.SillyTavern = {
|
||||
@@ -529,6 +530,7 @@ export const event_types = {
|
||||
CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
|
||||
TOOL_CALLS_PERFORMED: 'tool_calls_performed',
|
||||
TOOL_CALLS_RENDERED: 'tool_calls_rendered',
|
||||
CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown',
|
||||
};
|
||||
|
||||
export const eventSource = new EventEmitter([event_types.APP_READY]);
|
||||
@@ -563,7 +565,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();
|
||||
@@ -636,6 +638,7 @@ export const system_message_types = {
|
||||
MACROS: 'macros',
|
||||
WELCOME_PROMPT: 'welcome_prompt',
|
||||
ASSISTANT_NOTE: 'assistant_note',
|
||||
ASSISTANT_MESSAGE: 'assistant_message',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -737,6 +740,7 @@ async function getSystemMessages() {
|
||||
force_avatar: system_avatar,
|
||||
is_user: false,
|
||||
is_system: true,
|
||||
uses_system_ui: true,
|
||||
mes: await renderTemplateAsync('welcomePrompt'),
|
||||
extra: {
|
||||
isSmallSys: true,
|
||||
@@ -983,8 +987,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 +1011,7 @@ async function firstLoadInit() {
|
||||
initSettingsSearch();
|
||||
initBulkEdit();
|
||||
initReasoning();
|
||||
initWelcomeScreen();
|
||||
await initScrapers();
|
||||
initCustomSelectedSamplers();
|
||||
addDebugFunctions();
|
||||
@@ -1472,6 +1475,11 @@ function getCharacterBlock(item, id) {
|
||||
template.toggleClass('is_fav', item.fav || item.fav == 'true');
|
||||
template.find('.ch_fav').val(item.fav);
|
||||
|
||||
const isAssistant = item.avatar === getPermanentAssistantAvatar();
|
||||
if (!isAssistant) {
|
||||
template.find('.ch_assistant').remove();
|
||||
}
|
||||
|
||||
const description = item.data?.creator_notes || '';
|
||||
if (description) {
|
||||
template.find('.ch_description').text(description);
|
||||
@@ -2040,7 +2048,7 @@ export async function sendTextareaMessage() {
|
||||
}
|
||||
|
||||
if (textareaText && !selected_group && this_chid === undefined && name2 !== neutralCharacterName) {
|
||||
await newAssistantChat();
|
||||
await newAssistantChat({ temporary: false });
|
||||
}
|
||||
|
||||
Generate(generateType);
|
||||
@@ -2291,6 +2299,7 @@ function getMessageFromTemplate({
|
||||
timestamp,
|
||||
tokenCount,
|
||||
extra,
|
||||
type,
|
||||
}) {
|
||||
const mes = messageTemplate.clone();
|
||||
mes.attr({
|
||||
@@ -2302,6 +2311,7 @@ function getMessageFromTemplate({
|
||||
'bookmark_link': bookmarkLink,
|
||||
'force_avatar': !!forceAvatar,
|
||||
'timestamp': timestamp,
|
||||
...(type ? { type } : {}),
|
||||
});
|
||||
mes.find('.avatar img').attr('src', avatarImg);
|
||||
mes.find('.ch_name .name_text').text(characterName);
|
||||
@@ -2513,6 +2523,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
|
||||
timestamp: timestamp,
|
||||
extra: mes.extra,
|
||||
tokenCount: mes.extra?.token_count ?? 0,
|
||||
type: mes.extra?.type ?? '',
|
||||
...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token),
|
||||
};
|
||||
|
||||
@@ -2882,7 +2893,14 @@ export async function processCommands(message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sendSystemMessage(type, text, extra = {}) {
|
||||
/**
|
||||
* Gets a system message by type.
|
||||
* @param {string} type Type of system message
|
||||
* @param {string} [text] Text to be sent
|
||||
* @param {object} [extra] Additional data to be added to the message
|
||||
* @returns {object} System message object
|
||||
*/
|
||||
export function getSystemMessageByType(type, text, extra = {}) {
|
||||
const systemMessage = system_messages[type];
|
||||
|
||||
if (!systemMessage) {
|
||||
@@ -2905,7 +2923,17 @@ export function sendSystemMessage(type, text, extra = {}) {
|
||||
|
||||
newMessage.extra = Object.assign(newMessage.extra, extra);
|
||||
newMessage.extra.type = type;
|
||||
return newMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a system message to the chat.
|
||||
* @param {string} type Type of system message
|
||||
* @param {string} [text] Text to be sent
|
||||
* @param {object} [extra] Additional data to be added to the message
|
||||
*/
|
||||
export function sendSystemMessage(type, text, extra = {}) {
|
||||
const newMessage = getSystemMessageByType(type, text, extra);
|
||||
chat.push(newMessage);
|
||||
addOneMessage(newMessage);
|
||||
is_send_press = false;
|
||||
@@ -10112,8 +10140,17 @@ async function removeCharacterFromUI() {
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
async function newAssistantChat() {
|
||||
/**
|
||||
* Creates a new assistant chat.
|
||||
* @param {object} params - Parameters for the new assistant chat
|
||||
* @param {boolean} [params.temporary=false] I need a temporary secretary
|
||||
* @returns {Promise<void>} - A promise that resolves when the new assistant chat is created
|
||||
*/
|
||||
export async function newAssistantChat({ temporary = false } = {}) {
|
||||
await clearChat();
|
||||
if (!temporary) {
|
||||
return openPermanentAssistantChat();
|
||||
}
|
||||
chat.splice(0, chat.length);
|
||||
chat_metadata = {};
|
||||
setCharacterName(neutralCharacterName);
|
||||
@@ -10426,7 +10463,7 @@ jQuery(async function () {
|
||||
if (chatId) {
|
||||
return reject('Not in a temporary chat');
|
||||
}
|
||||
await newAssistantChat();
|
||||
await newAssistantChat({ temporary: true });
|
||||
return resolve('');
|
||||
};
|
||||
eventSource.once(event_types.CHAT_CHANGED, eventCallback);
|
||||
@@ -11093,6 +11130,9 @@ jQuery(async function () {
|
||||
});
|
||||
|
||||
if (id == 'option_select_chat') {
|
||||
if (this_chid === undefined && !is_send_press) {
|
||||
await openPermanentAssistantCard();
|
||||
}
|
||||
if ((selected_group && !is_group_generating) || (this_chid !== undefined && !is_send_press) || fromSlashCommand) {
|
||||
await displayPastChats();
|
||||
//this is just to avoid the shadow for past chat view when using /delchat
|
||||
@@ -11123,7 +11163,7 @@ jQuery(async function () {
|
||||
await doNewChat({ deleteCurrentChat: deleteCurrentChat });
|
||||
}
|
||||
if (!selected_group && this_chid === undefined && !is_send_press) {
|
||||
await newAssistantChat();
|
||||
await newAssistantChat({ temporary: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11176,9 +11216,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 {
|
||||
toastr.info('Please stop the message generation first.');
|
||||
@@ -12108,7 +12145,7 @@ jQuery(async function () {
|
||||
);
|
||||
break;*/
|
||||
default:
|
||||
await eventSource.emit('charManagementDropdown', target);
|
||||
await eventSource.emit(event_types.CHARACTER_MANAGEMENT_DROPDOWN, target);
|
||||
}
|
||||
$('#char-management-dropdown').prop('selectedIndex', 0);
|
||||
});
|
||||
|
@@ -23,6 +23,9 @@ import {
|
||||
neutralCharacterName,
|
||||
updateChatMetadata,
|
||||
system_message_types,
|
||||
getSystemMessageByType,
|
||||
printMessages,
|
||||
clearChat,
|
||||
} from '../script.js';
|
||||
import { selected_group } from './group-chats.js';
|
||||
import { power_user } from './power-user.js';
|
||||
@@ -37,6 +40,7 @@ import {
|
||||
saveBase64AsFile,
|
||||
extractTextFromOffice,
|
||||
download,
|
||||
getFileText,
|
||||
} from './utils.js';
|
||||
import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
|
||||
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
|
||||
@@ -1497,6 +1501,35 @@ jQuery(function () {
|
||||
download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json');
|
||||
});
|
||||
|
||||
$(document).on('click', '.assistant_note_import', async function () {
|
||||
const importFile = async () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await getFileText(file);
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||||
const messages = lines.map(line => JSON.parse(line));
|
||||
const metadata = messages.shift()?.chat_metadata || {};
|
||||
messages.unshift(getSystemMessageByType(system_message_types.ASSISTANT_NOTE));
|
||||
await clearChat();
|
||||
chat.splice(0, chat.length, ...messages);
|
||||
updateChatMetadata(metadata, true);
|
||||
await printMessages();
|
||||
} catch (error) {
|
||||
console.error('Error importing assistant chat:', error);
|
||||
toastr.error(t`It's either corrupted or not a valid JSONL file.`, t`Failed to import chat`);
|
||||
}
|
||||
};
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.jsonl';
|
||||
fileInput.addEventListener('change', importFile);
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Do not change. #attachFile is added by extension.
|
||||
$(document).on('click', '#attachFile', function () {
|
||||
$('#file_form_input').trigger('click');
|
||||
|
@@ -710,7 +710,7 @@ async function listGalleryCommand(args) {
|
||||
delete context.extensionSettings.gallery.folders[avatar];
|
||||
context.saveSettingsDebounced();
|
||||
});
|
||||
eventSource.on('charManagementDropdown', (selectedOptionId) => {
|
||||
eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (selectedOptionId) => {
|
||||
if (selectedOptionId === 'show_char_gallery') {
|
||||
showCharGallery();
|
||||
}
|
||||
|
@@ -1970,7 +1970,7 @@ export async function initPersonas() {
|
||||
|
||||
$('#char_connections_button').on('click', showCharConnections);
|
||||
|
||||
eventSource.on('charManagementDropdown', (target) => {
|
||||
eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => {
|
||||
if (target === 'convert_to_persona') {
|
||||
convertCharacterToPersona();
|
||||
}
|
||||
|
@@ -1,9 +1,13 @@
|
||||
<div data-type="assistant_note">
|
||||
<div>
|
||||
<div class="assistant_note_title">
|
||||
<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>
|
||||
<span data-i18n="Click the button to save it as a file.">Click the button to save it as a file.</span>
|
||||
</div>
|
||||
<div class="assistant_note_export menu_button menu_button_icon" data-i18n="[title]Export as JSONL" title="Export as JSONL">
|
||||
<button class="assistant_note_import menu_button menu_button_icon margin0" data-i18n="[title]Import from JSONL" title="Import from JSONL">
|
||||
<i class="fa-solid fa-file-import"></i>
|
||||
<span data-i18n="Load">Load</span>
|
||||
</button>
|
||||
<button class="assistant_note_export menu_button menu_button_icon margin0" data-i18n="[title]Export as JSONL" title="Export as JSONL">
|
||||
<i class="fa-solid fa-file-export"></i>
|
||||
</div>
|
||||
<span data-i18n="Save">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
82
public/scripts/templates/welcomePanel.html
Normal file
82
public/scripts/templates/welcomePanel.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<div class="welcomePanel">
|
||||
<div class="welcomeHeaderTitle">
|
||||
<img src="img/logo.png" alt="SillyTavern Logo" class="welcomeHeaderLogo">
|
||||
<span class="welcomeHeaderVersionDisplay">{{version}}</span>
|
||||
<div class="mes_button showRecentChats" title="Show recent chats" data-i18n="[title]Show recent chats">
|
||||
<i class="fa-solid fa-circle-chevron-down fa-fw fa-lg"></i>
|
||||
</div>
|
||||
<div class="mes_button hideRecentChats" title="Hide recent chats" data-i18n="[title]Hide recent chats">
|
||||
<i class="fa-solid fa-circle-xmark fa-fw fa-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcomeHeader">
|
||||
<div class="recentChatsTitle" data-i18n="Recent Chats">
|
||||
Recent Chats
|
||||
</div>
|
||||
<div class="welcomeShortcuts">
|
||||
<a class="menu_button menu_button_icon" target="_blank" href="https://docs.sillytavern.app/">
|
||||
<i class="fa-solid fa-question-circle"></i>
|
||||
<span data-i18n="Docs">Docs</span>
|
||||
</a>
|
||||
<a class="menu_button menu_button_icon" target="_blank" href="https://github.com/SillyTavern/SillyTavern">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
<span data-i18n="GitHub">GitHub</span>
|
||||
</a>
|
||||
<a class="menu_button menu_button_icon" target="_blank" href="https://discord.gg/sillytavern">
|
||||
<i class="fa-brands fa-discord"></i>
|
||||
<span data-i18n="Discord">Discord</span>
|
||||
</a>
|
||||
<span class="welcomeShortcutsSeparator">|</span>
|
||||
<button class="openTemporaryChat menu_button menu_button_icon">
|
||||
<i class="fa-solid fa-comment-dots"></i>
|
||||
<span data-i18n="Temporary Chat">Temporary Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcomeRecent">
|
||||
<div class="recentChatList">
|
||||
{{#if empty}}
|
||||
<div class="noRecentChat">
|
||||
<i class="fa-solid fa-comment-dots"></i>
|
||||
<span data-i18n="No recent chats">No recent chats</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#each chats}}
|
||||
{{#with this}}
|
||||
<div class="recentChat {{#if hidden}}hidden{{/if}} {{#if is_group}}group{{/if}}" data-file="{{chat_name}}" data-avatar="{{avatar}}" data-group="{{group}}">
|
||||
<div class="avatar" title="[Character] {{char_name}} File: {{avatar}}">
|
||||
<img src="{{char_thumbnail}}" alt="{{char_name}}">
|
||||
</div>
|
||||
<div class="recentChatInfo">
|
||||
<div class="chatNameContainer">
|
||||
<div class="chatName" title="{{file_name}}">
|
||||
<strong class="characterName">{{char_name}}</strong>
|
||||
<span>–</span>
|
||||
<span>{{chat_name}}</span>
|
||||
</div>
|
||||
<small class="chatDate" title="{{date_long}}">{{date_short}}</small>
|
||||
</div>
|
||||
<div class="chatMessageContainer">
|
||||
<div class="chatMessage" title="{{mes}}">
|
||||
{{mes}}
|
||||
</div>
|
||||
<div class="chatStats">
|
||||
<div class="counterBlock">
|
||||
<i class="fa-solid fa-comment fa-xs"></i>
|
||||
<small>{{chat_items}}</small>
|
||||
</div>
|
||||
<small class="fileSize">{{file_size}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/with}}
|
||||
{{/each}}
|
||||
{{#if more}}
|
||||
<button class="menu_button menu_button_icon showMoreChats">
|
||||
<small class="fa-solid fa-chevron-down fa-fw fa-1x"></small>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -1,3 +1,14 @@
|
||||
<strong data-i18n="If you're connected to an API, try asking me something!">
|
||||
If you're connected to an API, try asking me something!
|
||||
</strong>
|
||||
<div class="flex-container">
|
||||
<button class="menu_button menu_button_icon drawer-opener inline-flex" data-target="sys-settings-button">
|
||||
<i class="fa-solid fa-plug"></i>
|
||||
<span data-i18n="API Connections">API Connections</span>
|
||||
</button>
|
||||
<button class="menu_button menu_button_icon drawer-opener inline-flex" data-target="rightNavHolder">
|
||||
<i class="fa-solid fa-address-card"></i>
|
||||
<span data-i18n="Character Management">Character Management</span>
|
||||
</button>
|
||||
<button class="menu_button menu_button_icon drawer-opener inline-flex" data-target="extensions-settings-button">
|
||||
<i class="fa-solid fa-cubes"></i>
|
||||
<span data-i18n="Extensions">Extensions</span>
|
||||
</button>
|
||||
</div>
|
||||
|
413
public/scripts/welcome-screen.js
Normal file
413
public/scripts/welcome-screen.js
Normal file
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
addOneMessage,
|
||||
characters,
|
||||
chat,
|
||||
displayVersion,
|
||||
doNewChat,
|
||||
event_types,
|
||||
eventSource,
|
||||
getCharacters,
|
||||
getCurrentChatId,
|
||||
getRequestHeaders,
|
||||
getSystemMessageByType,
|
||||
getThumbnailUrl,
|
||||
is_send_press,
|
||||
neutralCharacterName,
|
||||
newAssistantChat,
|
||||
openCharacterChat,
|
||||
printCharactersDebounced,
|
||||
selectCharacterById,
|
||||
system_avatar,
|
||||
system_message_types,
|
||||
this_chid,
|
||||
} from '../script.js';
|
||||
import { getGroupAvatar, groups, is_group_generating, openGroupById, openGroupChat } from './group-chats.js';
|
||||
import { t } from './i18n.js';
|
||||
import { renderTemplateAsync } from './templates.js';
|
||||
import { accountStorage } from './util/AccountStorage.js';
|
||||
import { sortMoments, timestampToMoment } from './utils.js';
|
||||
|
||||
const assistantAvatarKey = 'assistant';
|
||||
const defaultAssistantAvatar = 'default_Assistant.png';
|
||||
|
||||
const DEFAULT_DISPLAYED = 3;
|
||||
const MAX_DISPLAYED = 15;
|
||||
|
||||
export function getPermanentAssistantAvatar() {
|
||||
const assistantAvatar = accountStorage.getItem(assistantAvatarKey);
|
||||
if (assistantAvatar === null) {
|
||||
return defaultAssistantAvatar;
|
||||
}
|
||||
|
||||
const character = characters.find(x => x.avatar === assistantAvatar);
|
||||
if (character === undefined) {
|
||||
accountStorage.removeItem(assistantAvatarKey);
|
||||
return defaultAssistantAvatar;
|
||||
}
|
||||
|
||||
return assistantAvatar;
|
||||
}
|
||||
|
||||
export async function openWelcomeScreen() {
|
||||
const currentChatId = getCurrentChatId();
|
||||
if (currentChatId !== undefined || chat.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendWelcomePanel();
|
||||
sendAssistantMessage();
|
||||
sendWelcomePrompt();
|
||||
}
|
||||
|
||||
function sendAssistantMessage() {
|
||||
const currentAssistantAvatar = getPermanentAssistantAvatar();
|
||||
const character = characters.find(x => x.avatar === currentAssistantAvatar);
|
||||
const name = character ? character.name : neutralCharacterName;
|
||||
const avatar = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar;
|
||||
|
||||
const message = {
|
||||
name: name,
|
||||
force_avatar: avatar,
|
||||
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.`,
|
||||
is_system: false,
|
||||
is_user: false,
|
||||
extra: {
|
||||
type: system_message_types.ASSISTANT_MESSAGE,
|
||||
},
|
||||
};
|
||||
|
||||
chat.push(message);
|
||||
addOneMessage(message, { scroll: false });
|
||||
}
|
||||
|
||||
function sendWelcomePrompt() {
|
||||
const message = getSystemMessageByType(system_message_types.WELCOME_PROMPT);
|
||||
chat.push(message);
|
||||
addOneMessage(message, { scroll: false });
|
||||
}
|
||||
|
||||
async function sendWelcomePanel() {
|
||||
try {
|
||||
const chatElement = document.getElementById('chat');
|
||||
const sendTextArea = document.getElementById('send_textarea');
|
||||
if (!chatElement) {
|
||||
console.error('Chat element not found');
|
||||
return;
|
||||
}
|
||||
const chats = await getRecentChats();
|
||||
const templateData = {
|
||||
chats,
|
||||
empty: !chats.length,
|
||||
version: displayVersion,
|
||||
more: chats.some(chat => chat.hidden),
|
||||
};
|
||||
const template = await renderTemplateAsync('welcomePanel', templateData);
|
||||
const fragment = document.createRange().createContextualFragment(template);
|
||||
fragment.querySelectorAll('.welcomePanel').forEach((root) => {
|
||||
const recentHiddenClass = 'recentHidden';
|
||||
const recentHiddenKey = 'WelcomePage_RecentChatsHidden';
|
||||
if (accountStorage.getItem(recentHiddenKey) === 'true') {
|
||||
root.classList.add(recentHiddenClass);
|
||||
}
|
||||
root.querySelectorAll('.showRecentChats').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
root.classList.remove(recentHiddenClass);
|
||||
accountStorage.setItem(recentHiddenKey, 'false');
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('.hideRecentChats').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
root.classList.add(recentHiddenClass);
|
||||
accountStorage.setItem(recentHiddenKey, 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
fragment.querySelectorAll('.recentChat').forEach((item) => {
|
||||
item.addEventListener('click', () => {
|
||||
const avatarId = item.getAttribute('data-avatar');
|
||||
const groupId = item.getAttribute('data-group');
|
||||
const fileName = item.getAttribute('data-file');
|
||||
if (avatarId && fileName) {
|
||||
void openRecentCharacterChat(avatarId, fileName);
|
||||
}
|
||||
if (groupId && fileName) {
|
||||
void openRecentGroupChat(groupId, fileName);
|
||||
}
|
||||
});
|
||||
});
|
||||
const hiddenChats = fragment.querySelectorAll('.recentChat.hidden');
|
||||
fragment.querySelectorAll('button.showMoreChats').forEach((button) => {
|
||||
const showRecentChatsTitle = t`Show more recent chats`;
|
||||
const hideRecentChatsTitle = t`Show less recent chats`;
|
||||
|
||||
button.setAttribute('title', showRecentChatsTitle);
|
||||
button.addEventListener('click', () => {
|
||||
const rotate = button.classList.contains('rotated');
|
||||
hiddenChats.forEach((chatItem) => {
|
||||
chatItem.classList.toggle('hidden', rotate);
|
||||
});
|
||||
button.classList.toggle('rotated', !rotate);
|
||||
button.setAttribute('title', rotate ? showRecentChatsTitle : hideRecentChatsTitle);
|
||||
});
|
||||
});
|
||||
fragment.querySelectorAll('button.openTemporaryChat').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
await newAssistantChat({ temporary: true });
|
||||
if (sendTextArea instanceof HTMLTextAreaElement) {
|
||||
sendTextArea.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
fragment.querySelectorAll('.recentChat.group').forEach((groupChat) => {
|
||||
const groupId = groupChat.getAttribute('data-group');
|
||||
const group = groups.find(x => x.id === groupId);
|
||||
if (group) {
|
||||
const avatar = groupChat.querySelector('.avatar');
|
||||
if (!avatar) {
|
||||
return;
|
||||
}
|
||||
const groupAvatar = getGroupAvatar(group);
|
||||
$(avatar).replaceWith(groupAvatar);
|
||||
}
|
||||
});
|
||||
chatElement.append(fragment.firstChild);
|
||||
} catch (error) {
|
||||
console.error('Welcome screen error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a recent character chat.
|
||||
* @param {string} avatarId Avatar file name
|
||||
* @param {string} fileName Chat file name
|
||||
*/
|
||||
async function openRecentCharacterChat(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);
|
||||
const currentChatId = getCurrentChatId();
|
||||
if (currentChatId === fileName) {
|
||||
console.debug(`Chat ${fileName} is already open.`);
|
||||
return;
|
||||
}
|
||||
await openCharacterChat(fileName);
|
||||
} catch (error) {
|
||||
console.error('Error opening recent chat:', error);
|
||||
toastr.error(t`Failed to open recent chat. See console for details.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a recent group chat.
|
||||
* @param {string} groupId Group ID
|
||||
* @param {string} fileName Chat file name
|
||||
*/
|
||||
async function openRecentGroupChat(groupId, fileName) {
|
||||
const group = groups.find(x => x.id === groupId);
|
||||
if (!group) {
|
||||
console.error(`Group not found for ID: ${groupId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openGroupById(groupId);
|
||||
const currentChatId = getCurrentChatId();
|
||||
if (currentChatId === fileName) {
|
||||
console.debug(`Chat ${fileName} is already open.`);
|
||||
return;
|
||||
}
|
||||
await openGroupChat(groupId, fileName);
|
||||
} catch (error) {
|
||||
console.error('Error opening recent group chat:', error);
|
||||
toastr.error(t`Failed to open recent group chat. See console for details.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of recent chats from the server.
|
||||
* @returns {Promise<RecentChat[]>} 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 {string} last_mes Timestamp of the last message
|
||||
* @property {string} avatar Avatar URL
|
||||
* @property {string} char_thumbnail Thumbnail URL
|
||||
* @property {string} char_name Character or group name
|
||||
* @property {string} date_short Date in short format
|
||||
* @property {string} date_long Date in long format
|
||||
* @property {string} group Group ID (if applicable)
|
||||
* @property {boolean} is_group Indicates if the chat is a group chat
|
||||
* @property {boolean} hidden Chat will be hidden by default
|
||||
*/
|
||||
async function getRecentChats() {
|
||||
const response = await fetch('/api/chats/recent', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ max: MAX_DISPLAYED }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to fetch recent character chats');
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @type {RecentChat[]} */
|
||||
const data = await response.json();
|
||||
|
||||
data.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)))
|
||||
.map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) }))
|
||||
.filter(t => t.character || t.group)
|
||||
.forEach(({ chat, character, group }, index) => {
|
||||
const chatTimestamp = timestampToMoment(chat.last_mes);
|
||||
chat.char_name = character?.name || group?.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 = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar;
|
||||
chat.is_group = !!group;
|
||||
chat.hidden = index >= DEFAULT_DISPLAYED;
|
||||
chat.avatar = chat.avatar || '';
|
||||
chat.group = chat.group || '';
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function openPermanentAssistantChat({ tryCreate = true, created = false } = {}) {
|
||||
const avatar = getPermanentAssistantAvatar();
|
||||
const characterId = characters.findIndex(x => x.avatar === avatar);
|
||||
if (characterId === -1) {
|
||||
if (!tryCreate) {
|
||||
console.error(`Character not found for avatar ID: ${avatar}. Cannot create.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Character not found for avatar ID: ${avatar}. Creating new assistant.`);
|
||||
await createPermanentAssistant();
|
||||
return openPermanentAssistantChat({ tryCreate: false, created: true });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error creating permanent assistant:', error);
|
||||
toastr.error(t`Failed to create ${neutralCharacterName}. See console for details.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await selectCharacterById(characterId);
|
||||
if (!created) {
|
||||
await doNewChat({ deleteCurrentChat: false });
|
||||
}
|
||||
console.log(`Opened permanent assistant chat for ${neutralCharacterName}.`, getCurrentChatId());
|
||||
} catch (error) {
|
||||
console.error('Error opening permanent assistant chat:', error);
|
||||
toastr.error(t`Failed to open permanent assistant chat. See console for details.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createPermanentAssistant() {
|
||||
if (is_group_generating || is_send_press) {
|
||||
throw new Error(t`Cannot create while generating.`);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('ch_name', neutralCharacterName);
|
||||
formData.append('file_name', defaultAssistantAvatar.replace('.png', ''));
|
||||
formData.append('creator_notes', t`Automatically created character. Feel free to edit.`);
|
||||
|
||||
try {
|
||||
const avatarResponse = await fetch(system_avatar);
|
||||
const avatarBlob = await avatarResponse.blob();
|
||||
formData.append('avatar', avatarBlob, defaultAssistantAvatar);
|
||||
} catch (error) {
|
||||
console.warn('Error fetching system avatar. Fallback image will be used.', error);
|
||||
}
|
||||
|
||||
const headers = getRequestHeaders();
|
||||
delete headers['Content-Type'];
|
||||
|
||||
const fetchResult = await fetch('/api/characters/create', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData,
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (!fetchResult.ok) {
|
||||
throw new Error(t`Creation request did not succeed.`);
|
||||
}
|
||||
|
||||
await getCharacters();
|
||||
}
|
||||
|
||||
export async function openPermanentAssistantCard() {
|
||||
const avatar = getPermanentAssistantAvatar();
|
||||
const characterId = characters.findIndex(x => x.avatar === avatar);
|
||||
if (characterId === -1) {
|
||||
toastr.info(t`Assistant not found. Try sending a chat message.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await selectCharacterById(characterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a character as the assistant.
|
||||
* @param {string?} characterId Character ID
|
||||
*/
|
||||
export function assignCharacterAsAssistant(characterId) {
|
||||
if (characterId === undefined) {
|
||||
return;
|
||||
}
|
||||
/** @type {import('./char-data.js').v1CharData} */
|
||||
const character = characters[characterId];
|
||||
if (!character) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentAssistantAvatar = getPermanentAssistantAvatar();
|
||||
if (currentAssistantAvatar === character.avatar) {
|
||||
if (character.avatar === defaultAssistantAvatar) {
|
||||
toastr.info(t`${character.name} is a system assistant. Choose another character.`);
|
||||
return;
|
||||
}
|
||||
|
||||
toastr.info(t`${character.name} is no longer your assistant.`);
|
||||
accountStorage.removeItem(assistantAvatarKey);
|
||||
return;
|
||||
}
|
||||
|
||||
accountStorage.setItem(assistantAvatarKey, character.avatar);
|
||||
printCharactersDebounced();
|
||||
toastr.success(t`Set ${character.name} as your assistant.`);
|
||||
}
|
||||
|
||||
export function initWelcomeScreen() {
|
||||
const events = [event_types.CHAT_CHANGED, event_types.APP_READY];
|
||||
for (const event of events) {
|
||||
eventSource.makeFirst(event, openWelcomeScreen);
|
||||
}
|
||||
|
||||
eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => {
|
||||
if (target !== 'set_as_assistant') {
|
||||
return;
|
||||
}
|
||||
assignCharacterAsAssistant(this_chid);
|
||||
});
|
||||
|
||||
eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => {
|
||||
if (oldAvatar === getPermanentAssistantAvatar()) {
|
||||
accountStorage.setItem(assistantAvatarKey, newAvatar);
|
||||
}
|
||||
});
|
||||
}
|
@@ -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%;
|
||||
@@ -6060,12 +6061,12 @@ body:not(.movingUI) .drawer-content.maximized {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 2px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mes_text div[data-type="assistant_note"]:has(.assistant_note_export)>div:not(.assistant_note_export) {
|
||||
.mes_text div[data-type="assistant_note"]:has(.assistant_note_export) > div {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.oneline-dropdown label {
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { promises as fsPromises } from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import express from 'express';
|
||||
@@ -22,6 +21,7 @@ import { readWorldInfoFile } from './worldinfo.js';
|
||||
import { invalidateThumbnail } from './thumbnails.js';
|
||||
import { importRisuSprites } from './sprites.js';
|
||||
import { getUserDirectories } from '../users.js';
|
||||
import { getChatInfo } from './chats.js';
|
||||
const defaultAvatarPath = './public/img/ai4.png';
|
||||
|
||||
// With 100 MB limit it would take roughly 3000 characters to reach this limit
|
||||
@@ -577,10 +577,10 @@ function charaFormatData(data, directories) {
|
||||
_.set(char, 'mes_example', data.mes_example || '');
|
||||
|
||||
// Old ST extension fields (for backward compatibility, will be deprecated)
|
||||
_.set(char, 'creatorcomment', data.creator_notes);
|
||||
_.set(char, 'creatorcomment', data.creator_notes || '');
|
||||
_.set(char, 'avatar', 'none');
|
||||
_.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime());
|
||||
_.set(char, 'talkativeness', data.talkativeness);
|
||||
_.set(char, 'talkativeness', data.talkativeness || 0.5);
|
||||
_.set(char, 'fav', data.fav == 'true');
|
||||
_.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
|
||||
|
||||
@@ -604,7 +604,7 @@ function charaFormatData(data, directories) {
|
||||
_.set(char, 'data.alternate_greetings', getAlternateGreetings(data));
|
||||
|
||||
// ST extension fields to V2 object
|
||||
_.set(char, 'data.extensions.talkativeness', data.talkativeness);
|
||||
_.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5);
|
||||
_.set(char, 'data.extensions.fav', data.fav == 'true');
|
||||
_.set(char, 'data.extensions.world', data.world || '');
|
||||
|
||||
@@ -943,7 +943,7 @@ router.post('/create', async function (request, response) {
|
||||
request.body.ch_name = sanitize(request.body.ch_name);
|
||||
|
||||
const char = JSON.stringify(charaFormatData(request.body, request.user.directories));
|
||||
const internalName = getPngName(request.body.ch_name, request.user.directories);
|
||||
const internalName = request.body.file_name || getPngName(request.body.ch_name, request.user.directories);
|
||||
const avatarName = `${internalName}.png`;
|
||||
const chatsPath = path.join(request.user.directories.chats, internalName);
|
||||
|
||||
@@ -1228,7 +1228,6 @@ router.post('/chats', validateAvatarUrlMiddleware, async function (request, resp
|
||||
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 +1247,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);
|
||||
|
@@ -351,6 +351,69 @@ async function checkChatIntegrity(filePath, integritySlug) {
|
||||
return chatIntegrity === integritySlug;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<ChatInfo>}
|
||||
*/
|
||||
export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false) {
|
||||
return new Promise(async (res) => {
|
||||
const fileStream = fs.createReadStream(pathToFile);
|
||||
const stats = await fs.promises.stat(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'] = isGroup ? itemCounter : (itemCounter - 1);
|
||||
chatData['mes'] = jsonData['mes'] || '[The chat is empty]';
|
||||
chatData['last_mes'] = jsonData['send_date'] || stats.mtimeMs;
|
||||
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('/save', validateAvatarUrlMiddleware, async function (request, response) {
|
||||
@@ -809,3 +872,79 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
|
||||
return response.status(500).json({ error: 'Search failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/recent', async function (request, response) {
|
||||
try {
|
||||
/** @type {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} */
|
||||
const allChatFiles = [];
|
||||
|
||||
const getCharacterChatFiles = async () => {
|
||||
const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true });
|
||||
const pngFiles = pngDirents.filter(e => e.isFile() && path.extname(e.name) === '.png').map(e => e.name);
|
||||
|
||||
for (const pngFile of pngFiles) {
|
||||
const chatsDirectory = pngFile.replace('.png', '');
|
||||
const pathToChats = path.join(request.user.directories.chats, chatsDirectory);
|
||||
if (!fs.existsSync(pathToChats)) {
|
||||
continue;
|
||||
}
|
||||
const pathStats = await fs.promises.stat(pathToChats);
|
||||
if (pathStats.isDirectory()) {
|
||||
const chatFiles = await fs.promises.readdir(pathToChats);
|
||||
const jsonlFiles = chatFiles.filter(file => path.extname(file) === '.jsonl');
|
||||
|
||||
for (const file of jsonlFiles) {
|
||||
const filePath = path.join(pathToChats, file);
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
allChatFiles.push({ pngFile, filePath, mtime: stats.mtimeMs });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getGroupChatFiles = async () => {
|
||||
const groupDirents = await fs.promises.readdir(request.user.directories.groups, { withFileTypes: true });
|
||||
const groups = groupDirents.filter(e => e.isFile() && path.extname(e.name) === '.json').map(e => e.name);
|
||||
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const groupPath = path.join(request.user.directories.groups, group);
|
||||
const groupContents = await fs.promises.readFile(groupPath, 'utf8');
|
||||
const groupData = JSON.parse(groupContents);
|
||||
|
||||
if (Array.isArray(groupData.chats)) {
|
||||
for (const chat of groupData.chats) {
|
||||
const filePath = path.join(request.user.directories.groupChats, `${chat}.jsonl`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
allChatFiles.push({ groupId: groupData.id, filePath, mtime: stats.mtimeMs });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip group files that can't be read or parsed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles()]);
|
||||
|
||||
const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER);
|
||||
const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max);
|
||||
const jsonFilesPromise = recentChats.map((file) => {
|
||||
return file.groupId
|
||||
? getChatInfo(file.filePath, { group: file.groupId }, true)
|
||||
: getChatInfo(file.filePath, { avatar: file.pngFile }, false);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
Reference in New Issue
Block a user