Merge branch 'neo-server' into parser-v2

This commit is contained in:
LenAnderson
2024-04-12 21:48:51 -04:00
138 changed files with 5743 additions and 1390 deletions

View File

@ -4,6 +4,7 @@ npm-debug.log
readme*
Start.bat
/dist
/backups/
/backups
cloudflared.exe
access.log
/data

1
.gitignore vendored
View File

@ -25,6 +25,7 @@ public/stats.json
/docker/config
/docker/user
/docker/extensions
/docker/data
.DS_Store
public/settings.json
/thumbnails

View File

@ -1,4 +1,4 @@
FROM node:19.1.0-alpine3.16
FROM node:lts-alpine3.18
# Arguments
ARG APP_HOME=/home/node/app
@ -26,19 +26,9 @@ COPY . ./
# Copy default chats, characters and user avatars to <folder>.default folder
RUN \
IFS="," RESOURCES="assets,backgrounds,user,context,instruct,QuickReplies,movingUI,themes,characters,chats,groups,group chats,User Avatars,worlds,OpenAI Settings,NovelAI Settings,KoboldAI Settings,TextGen Settings" && \
\
echo "*** Store default $RESOURCES in <folder>.default ***" && \
for R in $RESOURCES; do mv "public/$R" "public/$R.default"; done || true && \
\
echo "*** Create symbolic links to config directory ***" && \
for R in $RESOURCES; do ln -s "../config/$R" "public/$R"; done || true && \
\
rm -f "config.yaml" "public/settings.json" || true && \
rm -f "config.yaml" || true && \
ln -s "./config/config.yaml" "config.yaml" || true && \
ln -s "../config/settings.json" "public/settings.json" || true && \
mkdir "config" || true && \
mkdir -p "public/user" || true
mkdir "config" || true
# Cleanup unnecessary files
RUN \

View File

@ -1,8 +1,12 @@
# -- NETWORK CONFIGURATION --
# -- DATA CONFIGURATION --
# Root directory for user data storage
dataRoot: ./data
# -- SERVER CONFIGURATION --
# Listen for incoming connections
listen: false
# Server port
port: 8000
# -- SECURITY CONFIGURATION --
# Toggle whitelist mode
whitelistMode: true
# Whitelist of allowed IP addresses
@ -16,6 +20,12 @@ basicAuthUser:
password: "password"
# Enables CORS proxy middleware
enableCorsProxy: false
# Enable multi-user mode
enableUserAccounts: false
# Enable discreet login mode: hides user list on the login screen
enableDiscreetLogin: false
# Used to sign session cookies. Will be auto-generated if not set
cookieSecret: ''
# Disable security checks - NOT RECOMMENDED
securityOverride: false
# -- ADVANCED CONFIGURATION --

View File

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 B

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 487 KiB

After

Width:  |  Height:  |  Size: 487 KiB

View File

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 307 KiB

View File

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 318 KiB

View File

Before

Width:  |  Height:  |  Size: 581 KiB

After

Width:  |  Height:  |  Size: 581 KiB

View File

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 561 KiB

View File

Before

Width:  |  Height:  |  Size: 505 KiB

After

Width:  |  Height:  |  Size: 505 KiB

View File

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 443 KiB

View File

Before

Width:  |  Height:  |  Size: 480 KiB

After

Width:  |  Height:  |  Size: 480 KiB

View File

Before

Width:  |  Height:  |  Size: 660 KiB

After

Width:  |  Height:  |  Size: 660 KiB

View File

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 371 KiB

View File

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 616 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 305 KiB

View File

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 436 KiB

View File

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

View File

Before

Width:  |  Height:  |  Size: 629 KiB

After

Width:  |  Height:  |  Size: 629 KiB

View File

Before

Width:  |  Height:  |  Size: 656 KiB

After

Width:  |  Height:  |  Size: 656 KiB

View File

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 528 KiB

View File

@ -1,4 +1,108 @@
[
{
"filename": "settings.json",
"type": "settings"
},
{
"filename": "themes/Dark Lite.json",
"type": "theme"
},
{
"filename": "themes/Cappuccino.json",
"type": "theme"
},
{
"filename": "backgrounds/__transparent.png",
"type": "background"
},
{
"filename": "backgrounds/_black.jpg",
"type": "background"
},
{
"filename": "backgrounds/_white.jpg",
"type": "background"
},
{
"filename": "backgrounds/bedroom clean.jpg",
"type": "background"
},
{
"filename": "backgrounds/bedroom cyberpunk.jpg",
"type": "background"
},
{
"filename": "backgrounds/bedroom red.jpg",
"type": "background"
},
{
"filename": "backgrounds/bedroom tatami.jpg",
"type": "background"
},
{
"filename": "backgrounds/cityscape medieval market.jpg",
"type": "background"
},
{
"filename": "backgrounds/cityscape medieval night.jpg",
"type": "background"
},
{
"filename": "backgrounds/cityscape postapoc.jpg",
"type": "background"
},
{
"filename": "backgrounds/forest treehouse fireworks air baloons (by kallmeflocc).jpg",
"type": "background"
},
{
"filename": "backgrounds/japan classroom side.jpg",
"type": "background"
},
{
"filename": "backgrounds/japan classroom.jpg",
"type": "background"
},
{
"filename": "backgrounds/japan path cherry blossom.jpg",
"type": "background"
},
{
"filename": "backgrounds/japan university.jpg",
"type": "background"
},
{
"filename": "backgrounds/landscape autumn great tree.jpg",
"type": "background"
},
{
"filename": "backgrounds/landscape beach day.png",
"type": "background"
},
{
"filename": "backgrounds/landscape beach night.jpg",
"type": "background"
},
{
"filename": "backgrounds/landscape mountain lake.jpg",
"type": "background"
},
{
"filename": "backgrounds/landscape postapoc.jpg",
"type": "background"
},
{
"filename": "backgrounds/landscape winter lake house.jpg",
"type": "background"
},
{
"filename": "backgrounds/royal.jpg",
"type": "background"
},
{
"filename": "backgrounds/tavern day.jpg",
"type": "background"
},
{
"filename": "default_Seraphina.png",
"type": "character"
@ -211,7 +315,6 @@
"filename": "presets/novel/Writers-Daemon-Kayra.json",
"type": "novel_preset"
},
{
"filename": "presets/textgen/Asterism.json",
"type": "textgen_preset"
@ -511,5 +614,17 @@
{
"filename": "presets/instruct/simple-proxy-for-tavern.json",
"type": "instruct"
},
{
"filename": "presets/moving-ui/Default.json",
"type": "moving_ui"
},
{
"filename": "presets/moving-ui/Black Magic Time.json",
"type": "moving_ui"
},
{
"filename": "presets/quick-replies/Default.json",
"type": "quick_replies"
}
]

View File

@ -95,7 +95,7 @@
"user_prompt_bias": "",
"show_user_prompt_bias": true,
"markdown_escape_strings": "",
"fast_ui_mode": false,
"fast_ui_mode": true,
"avatar_style": 0,
"chat_display": 0,
"chat_width": 50,
@ -115,16 +115,17 @@
"italics_text_color": "rgba(145, 145, 145, 1)",
"underline_text_color": "rgba(188, 231, 207, 1)",
"quote_text_color": "rgba(225, 138, 36, 1)",
"chat_tint_color": "rgba(23, 23, 23, 1)",
"blur_tint_color": "rgba(23, 23, 23, 1)",
"user_mes_blur_tint_color": "rgba(0, 0, 0, 0.9)",
"bot_mes_blur_tint_color": "rgba(0, 0, 0, 0.9)",
"user_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)",
"bot_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)",
"shadow_color": "rgba(0, 0, 0, 1)",
"waifuMode": false,
"movingUI": false,
"movingUIState": {},
"movingUIPreset": "Default",
"noShadows": true,
"theme": "Default (Dark) 1.7.1",
"theme": "Dark Lite",
"auto_swipe": false,
"auto_swipe_minimum_length": 0,
"auto_swipe_blacklist": [],
@ -139,7 +140,7 @@
"hotswap_enabled": true,
"timer_enabled": false,
"timestamps_enabled": true,
"timestamp_model_icon": false,
"timestamp_model_icon": true,
"mesIDDisplay_enabled": false,
"max_context_unlocked": false,
"prefer_character_prompt": true,

View File

@ -0,0 +1,35 @@
{
"name": "Cappuccino",
"blur_strength": 3,
"main_text_color": "rgba(255, 255, 255, 1)",
"italics_text_color": "rgba(230, 210, 190, 1)",
"underline_text_color": "rgba(205, 180, 160, 1)",
"quote_text_color": "rgba(165, 140, 115, 1)",
"blur_tint_color": "rgba(34, 30, 32, 0.95)",
"chat_tint_color": "rgba(50, 45, 50, 0.75)",
"user_mes_blur_tint_color": "rgba(34, 30, 32, 0.75)",
"bot_mes_blur_tint_color": "rgba(34, 30, 32, 0.75)",
"shadow_color": "rgba(0, 0, 0, 0.3)",
"shadow_width": 1,
"border_color": "rgba(80, 80, 80, 0.89)",
"font_scale": 1,
"fast_ui_mode": false,
"waifuMode": false,
"avatar_style": 0,
"chat_display": 1,
"noShadows": false,
"chat_width": 50,
"timer_enabled": false,
"timestamps_enabled": true,
"timestamp_model_icon": true,
"mesIDDisplay_enabled": true,
"message_token_count_enabled": false,
"expand_message_actions": false,
"enableZenSliders": false,
"enableLabMode": false,
"hotswap_enabled": true,
"custom_css": "",
"bogus_folders": true,
"reduced_motion": false,
"compact_input_area": true
}

View File

@ -0,0 +1,35 @@
{
"name": "Dark Lite",
"blur_strength": 10,
"main_text_color": "rgba(220, 220, 210, 1)",
"italics_text_color": "rgba(145, 145, 145, 1)",
"underline_text_color": "rgba(188, 231, 207, 1)",
"quote_text_color": "rgba(225, 138, 36, 1)",
"blur_tint_color": "rgba(23, 23, 23, 1)",
"chat_tint_color": "rgba(23, 23, 23, 1)",
"user_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)",
"bot_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)",
"shadow_color": "rgba(0, 0, 0, 1)",
"shadow_width": 2,
"border_color": "rgba(0, 0, 0, 1)",
"font_scale": 1,
"fast_ui_mode": true,
"waifuMode": false,
"avatar_style": 0,
"chat_display": 0,
"noShadows": true,
"chat_width": 50,
"timer_enabled": false,
"timestamps_enabled": true,
"timestamp_model_icon": true,
"mesIDDisplay_enabled": false,
"message_token_count_enabled": false,
"expand_message_actions": false,
"enableZenSliders": "",
"enableLabMode": "",
"hotswap_enabled": true,
"custom_css": "",
"bogus_folders": true,
"reduced_motion": false,
"compact_input_area": true
}

View File

@ -8,7 +8,6 @@ services:
ports:
- "8000:8000"
volumes:
- "./extensions:/home/node/app/public/scripts/extensions/third-party"
- "./config:/home/node/app/config"
- "./user:/home/node/app/public/user"
- "./data:/home/node/app/data"
restart: unless-stopped

View File

@ -1,38 +1,14 @@
#!/bin/sh
# Initialize missing user files
IFS="," RESOURCES="assets,backgrounds,user,context,instruct,QuickReplies,movingUI,themes,characters,chats,groups,group chats,User Avatars,worlds,OpenAI Settings,NovelAI Settings,KoboldAI Settings,TextGen Settings"
for R in $RESOURCES; do
if [ ! -e "config/$R" ]; then
echo "Resource not found, copying from defaults: $R"
cp -r "public/$R.default" "config/$R"
fi
done
if [ ! -e "config/config.yaml" ]; then
echo "Resource not found, copying from defaults: config.yaml"
cp -r "default/config.yaml" "config/config.yaml"
fi
if [ ! -e "config/settings.json" ]; then
echo "Resource not found, copying from defaults: settings.json"
cp -r "default/settings.json" "config/settings.json"
fi
CONFIG_FILE="config.yaml"
echo "Starting with the following config:"
cat $CONFIG_FILE
if grep -q "listen: false" $CONFIG_FILE; then
echo -e "\033[1;31mThe listen parameter is set to false. If you can't connect to the server, edit the \"docker/config/config.yaml\" file and restart the container.\033[0m"
sleep 5
fi
if grep -q "whitelistMode: true" $CONFIG_FILE; then
echo -e "\033[1;31mThe whitelistMode parameter is set to true. If you can't connect to the server, edit the \"docker/config/config.yaml\" file and restart the container.\033[0m"
sleep 5
fi
# Start the server
exec node server.js
exec node server.js --listen

19
index.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
import { UserDirectoryList, User } from "./src/users";
declare global {
namespace Express {
export interface Request {
user: {
profile: User;
directories: UserDirectoryList;
};
}
}
}
declare module 'express-session' {
export interface SessionData {
handle: string;
// other properties...
}
}

View File

@ -12,6 +12,9 @@
},
"exclude": [
"node_modules",
"**/node_modules/*"
"**/node_modules/*",
"public/lib",
"backups/*",
"data/*"
]
}

957
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,17 +4,20 @@
"@agnai/web-tokenizers": "^0.1.3",
"@dqbd/tiktoken": "^1.0.13",
"@zeldafan0225/ai_horde": "^4.0.1",
"archiver": "^7.0.1",
"bing-translate-api": "^2.9.1",
"body-parser": "^1.20.2",
"command-exists": "^1.2.9",
"compression": "^1",
"cookie-parser": "^1.4.6",
"cookie-session": "^2.1.0",
"cors": "^2.8.5",
"csrf-csrf": "^2.2.3",
"express": "^4.19.2",
"form-data": "^4.0.0",
"google-translate-api-browser": "^3.0.1",
"gpt3-tokenizer": "^1.1.5",
"helmet": "^7.1.0",
"ip-matching": "^2.1.2",
"ipaddr.js": "^2.0.1",
"jimp": "^0.22.10",
@ -22,10 +25,12 @@
"mime-types": "^2.1.35",
"multer": "^1.4.5-lts.1",
"node-fetch": "^2.6.11",
"node-persist": "^4.0.1",
"open": "^8.4.2",
"png-chunk-text": "^1.0.0",
"png-chunks-encode": "^1.0.0",
"png-chunks-extract": "^1.0.0",
"rate-limiter-flexible": "^5.0.0",
"response-time": "^2.3.2",
"sanitize-filename": "^1.6.3",
"sillytavern-transformers": "^2.14.6",
@ -45,6 +50,9 @@
"vectra": {
"openai": "^4.17.0"
},
"load-bmfont": {
"phin": "^3.7.1"
},
"axios": {
"follow-redirects": "^1.15.4"
},
@ -59,7 +67,7 @@
"type": "git",
"url": "https://github.com/SillyTavern/SillyTavern.git"
},
"version": "1.11.7",
"version": "1.12.0-preview",
"scripts": {
"start": "node server.js",
"start-multi": "node server.js --disableCsrf",
@ -76,6 +84,7 @@
},
"main": "server.js",
"devDependencies": {
"@types/jquery": "^3.5.29",
"eslint": "^8.55.0",
"jquery": "^3.6.4"
}

View File

@ -106,7 +106,6 @@ function addMissingConfigValues() {
*/
function createDefaultFiles() {
const files = {
settings: './public/settings.json',
config: './config.yaml',
user: './public/css/user.css',
};
@ -167,29 +166,6 @@ function copyWasmFiles() {
}
}
/**
* Moves the custom background into settings.json.
*/
function migrateBackground() {
if (!fs.existsSync('./public/css/bg_load.css')) return;
const bgCSS = fs.readFileSync('./public/css/bg_load.css', 'utf-8');
const bgMatch = /url\('([^']*)'\)/.exec(bgCSS);
if (!bgMatch) return;
const bgFilename = bgMatch[1].replace('../backgrounds/', '');
const settings = fs.readFileSync('./public/settings.json', 'utf-8');
const settingsJSON = JSON.parse(settings);
if (Object.hasOwn(settingsJSON, 'background')) {
console.log(color.yellow('Both bg_load.css and the "background" setting exist. Please delete bg_load.css manually.'));
return;
}
settingsJSON.background = { name: bgFilename, url: `url('backgrounds/${bgFilename}')` };
fs.writeFileSync('./public/settings.json', JSON.stringify(settingsJSON, null, 4));
fs.rmSync('./public/css/bg_load.css');
}
try {
// 0. Convert config.conf to config.yaml
convertConfig();
@ -199,8 +175,6 @@ try {
copyWasmFiles();
// 3. Add missing config values
addMissingConfigValues();
// 4. Migrate bg_load.css to settings.json
migrateBackground();
} catch (error) {
console.error(error);
}

View File

@ -1 +0,0 @@
# Put images here to select them as a user persona avatar.

View File

@ -1 +0,0 @@
Put ambient audio files here.

View File

@ -1 +0,0 @@
Put bgm audio files here

View File

@ -1 +0,0 @@
Put blip audio files here

View File

@ -1 +0,0 @@
Put live2d model folders here

View File

@ -1 +0,0 @@
Put VRM animation files here

View File

@ -1 +0,0 @@
Put VRM model files here

View File

@ -1,8 +0,0 @@
# Put PNG character cards here.
To create a sprites folder, name it the same as your character (NOT the PNG file).
For example:
- Character: /characters/Asuka Langley.png
- Sprite: /characters/Asuka Langley/joy.png

View File

@ -1,5 +0,0 @@
# Put Chat JSONL files here in subfolders corresponding to character names
For example:
- /chats/Robot/chat.jsonl

5
public/css/accounts.css Normal file
View File

@ -0,0 +1,5 @@
.userAccount {
border: 1px solid var(--SmartThemeBorderColor);
padding: 5px 10px;
border-radius: 5px;
}

35
public/css/login.css Normal file
View File

@ -0,0 +1,35 @@
body.login #shadow_popup {
opacity: 1;
display: flex;
}
body.login .logo {
max-width: 30px;
}
body.login #logoBlock {
align-items: center;
margin: 0 auto;
gap: 10px;
}
body.login .userSelect {
display: flex;
flex-direction: column;
color: var(--SmartThemeBodyColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 5px;
padding: 3px 5px;
width: min-content;
cursor: pointer;
margin: 5px 0;
transition: background-color 0.15s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
body.login .userSelect:hover {
background-color: var(--black30a);
}

View File

@ -1 +0,0 @@
# Put Group Chat JSONL files here

View File

@ -1 +0,0 @@
# Put Group JSON files here

BIN
public/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -88,6 +88,7 @@
<script type="module" src="scripts/bulk-edit.js"></script>
<script type="module" src="scripts/cfg-scale.js"></script>
<script type="module" src="scripts/chats.js"></script>
<script type="module" src="scripts/user.js"></script>
<title>SillyTavern</title>
</head>
@ -3474,7 +3475,21 @@
<small id="version_display"></small>
</div>
<div name="UserSettingsRowTwo" class="flex-container flexFlowRow">
<textarea id="settingsSearch" class="textarea_compact wide100p" rows="1" placeholder="Search Settings" data-i18n="[placeholder]Search Settings"></textarea>
<div id="account_controls" class="flex-container">
<div id="account_button" class="margin0 menu_button_icon menu_button">
<i class="fa-fw fa-solid fa-user-shield"></i>
<span data-i18n="Account">Account</span>
</div>
<div id="admin_button" class="margin0 menu_button_icon menu_button" >
<i class="fa-fw fa-solid fa-user-tie"></i>
<span data-i18n="Admin Panel">Admin Panel</span>
</div>
<div id="logout_button" class="margin0 menu_button_icon menu_button">
<i class="fa-fw fa-solid fa-right-from-bracket"></i>
<span data-i18n="Logout">Logout</span>
</div>
</div>
<textarea id="settingsSearch" class="textarea_compact flex1" rows="1" placeholder="Search Settings" data-i18n="[placeholder]Search Settings"></textarea>
</div>
</div>
<div id="user-settings-block-content" class="flex-container spaceEvenly">
@ -5349,17 +5364,16 @@
Enable simple UI mode
</span>
</label>
<h3 data-i18n="Your Persona">
Your Persona
</h3>
<div class="justifyLeft margin-bot-10px">
<span data-i18n="Before you get started, you must select a user name.">
Before you get started, you must select a user name.
<span data-i18n="Before you get started, you must select a persona name.">
Before you get started, you must select a persona name.
</span>
This can be changed at any time via the <code><i class="fa-solid fa-face-smile"></i></code> icon.
</div>
<h4 data-i18n="UI Language:">UI Language:</h4>
<select name="onboarding_ui_language">
<option value="en">English</option>
</select>
<h4 data-i18n="User Name:">User Name:</h4>
<h4 data-i18n="Persona Name:">Persona Name:</h4>
</div>
</div>
<div id="group_member_template" class="template_element">

81
public/login.html Normal file
View File

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<base href="/">
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, viewport-fit=cover, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="darkreader-lock">
<meta name="robots" content="noindex, nofollow" />
<link rel="apple-touch-icon" sizes="57x57" href="img/apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="72x72" href="img/apple-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="114x114" href="img/apple-icon-114x114.png" />
<link rel="apple-touch-icon" sizes="144x144" href="img/apple-icon-144x144.png" />
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" type="text/css" href="style.css">
<link rel="stylesheet" type="text/css" href="css/st-tailwind.css">
<link rel="stylesheet" type="text/css" href="css/login.css">
<link rel="manifest" crossorigin="use-credentials" href="manifest.json">
<link href="webfonts/NotoSans/stylesheet.css" rel="stylesheet">
<!-- fontawesome webfonts-->
<link href="css/fontawesome.css" rel="stylesheet">
<link href="css/solid.css" rel="stylesheet">
<link href="css/user.css" rel="stylesheet">
<script src="lib/jquery-3.5.1.min.js"></script>
<script src="scripts/login.js"></script>
<title>SillyTavern</title>
</head>
<body class="login">
<div id="shadow_popup" style="opacity: 0;">
<div id="dialogue_popup">
<div id="dialogue_popup_holder">
<div id="dialogue_popup_text">
<div id="userSelectBlock" class="flex-container flexFlowColumn alignItemsCenter">
<h2 id="logoBlock" class="flex-container">
<img src="img/logo.png" alt="SillyTavern" class="logo">
<span>Welcome to SillyTavern</span>
</h2>
<h3 id="normalLoginPrompt">
Select a User
</h3>
<h3 id="discreetLoginPrompt">
Enter Login Details
</h3>
<div id="userListBlock" class="wide100p">
<div id="userList" class="flex-container justifyCenter"></div>
<div id="handleEntryBlock" style="display:none;" class="flex-container flexFlowColumn alignItemsCenter">
<input id="userHandle" class="text_pole" type="text" placeholder="User handle" autocomplete="username">
</div>
<div id="passwordEntryBlock" style="display:none;"
class="flex-container flexFlowColumn alignItemsCenter">
<input id="userPassword" class="text_pole" type="password" placeholder="Password" autocomplete="current-password">
<a id="recoverPassword" href="#" onclick="return false;">Forgot password?</a>
<div class="flex-container">
<div id="loginButton" class="menu_button">Login</div>
</div>
</div>
<div id="passwordRecoveryBlock" style="display:none;"
class="flex-container flexFlowColumn alignItemsCenter">
<div id="recoverMessage">
Recovery code has been posted to the server console.
</div>
<input id="recoveryCode" class="text_pole" type="text" placeholder="Recovery code">
<input id="newPassword" class="text_pole" type="password" placeholder="New password" autocomplete="new-password">
<div class="flex-container flexGap10">
<div id="sendRecovery" class="menu_button">Send</div>
<div id="cancelRecovery" class="menu_button">Cancel</div>
</div>
</div>
</div>
<div class="neutral_warning" id="errorMessage">
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -211,6 +211,7 @@ import { loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermati
import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId } from './scripts/chats.js';
import { initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js';
import { callGenericPopup } from './scripts/popup.js';
import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
@ -665,13 +666,15 @@ async function getSystemMessages() {
registerPromptManagerMigration();
$(document).ajaxError(function myErrorHandler(_, xhr) {
// Cohee: CSRF doesn't error out in multiple tabs anymore, so this is unnecessary
/*
if (xhr.status == 403) {
toastr.warning(
'doubleCsrf errors in console are NORMAL in this case. If you want to run ST in multiple tabs, start the server with --disableCsrf option.',
'Looks like you\'ve opened SillyTavern in another browser tab',
{ timeOut: 0, extendedTimeOut: 0, preventDuplicates: true },
);
}
} */
});
async function getClientVersion() {
@ -1500,7 +1503,7 @@ function getCharacterSource(chId = this_chid) {
}
async function getCharacters() {
var response = await fetch('/api/characters/all', {
const response = await fetch('/api/characters/all', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
@ -1508,11 +1511,9 @@ async function getCharacters() {
}),
});
if (response.ok === true) {
var getData = ''; //RossAscends: reset to force array to update to account for deleted character.
getData = await response.json();
const load_ch_count = Object.getOwnPropertyNames(getData);
for (var i = 0; i < load_ch_count.length; i++) {
characters[i] = [];
characters.splice(0, characters.length);
const getData = await response.json();
for (let i = 0; i < getData.length; i++) {
characters[i] = getData[i];
characters[i]['name'] = DOMPurify.sanitize(characters[i]['name']);
@ -6066,7 +6067,7 @@ async function doOnboarding(avatarId) {
template.find('input[name="enable_simple_mode"]').on('input', function () {
simpleUiMode = $(this).is(':checked');
});
var userName = await callPopup(template, 'input', name1);
let userName = await callPopup(template, 'input', currentUser?.name || name1);
if (userName) {
userName = userName.replace('\n', ' ');
@ -6120,6 +6121,8 @@ async function getSettings() {
$('#your_name').val(name1);
}
await setUserControls(data.enable_accounts);
// Allow subscribers to mutate settings
eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);
@ -10137,6 +10140,7 @@ jQuery(async function () {
'#character_cross',
'#avatar-and-name-block',
'#shadow_popup',
'.shadow_popup',
'#world_popup',
'.ui-widget',
'.text_pole',

View File

@ -291,7 +291,7 @@ class BulkTagPopupHandler {
// Find mutual tags for multiple characters
const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid)));
const mutualTags = allTags.reduce((mutual, characterTags) =>
mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id))
mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)),
);
this.currentMutualTags = mutualTags.sort(compareTagsForSort);
@ -694,7 +694,7 @@ class BulkEditOverlay {
} else {
character.classList.remove(BulkEditOverlay.selectedClass);
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
this.#selectedCharacters = this.#selectedCharacters.filter(item => String(characterId) !== item)
this.#selectedCharacters = this.#selectedCharacters.filter(item => String(characterId) !== item);
}
this.updateSelectedCount();
@ -816,7 +816,7 @@ class BulkEditOverlay {
<span>Also delete the chat files</span>
</label>
</div>`;
}
};
/**
* Request user input before concurrently handle deletion

View File

@ -263,7 +263,7 @@ async function RA_autoloadchat() {
await selectCharacterById(String(active_character_id));
// Do a little tomfoolery to spoof the tag selector
const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`)
const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`);
applyTagsOnCharacterSelect.call(selectedCharElement);
}
}

View File

@ -44,22 +44,29 @@ function isConvertible(type) {
}
/**
* Mark message as hidden (system message).
* @param {number} messageId Message ID
* @param {JQuery<Element>} messageBlock Message UI element
* @returns
* Mark a range of messages as hidden ("is_system") or not.
* @param {number} start Starting message ID
* @param {number} end Ending message ID (inclusive)
* @param {boolean} unhide If true, unhide the messages instead.
* @returns {Promise<void>}
*/
export async function hideChatMessage(messageId, messageBlock) {
const chatId = getCurrentChatId();
export async function hideChatMessageRange(start, end, unhide) {
if (!getCurrentChatId()) return;
if (!chatId || isNaN(messageId)) return;
if (isNaN(start)) return;
if (!end) end = start;
const hide = !unhide;
for (let messageId = start; messageId <= end; messageId++) {
const message = chat[messageId];
if (!message) continue;
if (!message) return;
const messageBlock = $(`.mes[mesid="${messageId}"]`);
if (!messageBlock.length) continue;
message.is_system = true;
messageBlock.attr('is_system', String(true));
message.is_system = hide;
messageBlock.attr('is_system', String(hide));
}
// Reload swipes. Useful when a last message is hidden.
hideSwipeButtons();
@ -69,28 +76,25 @@ export async function hideChatMessage(messageId, messageBlock) {
}
/**
* Mark message as visible (non-system message).
* Mark message as hidden (system message).
* @deprecated Use hideChatMessageRange.
* @param {number} messageId Message ID
* @param {JQuery<Element>} messageBlock Message UI element
* @returns
* @param {JQuery<Element>} _messageBlock Unused
* @returns {Promise<void>}
*/
export async function unhideChatMessage(messageId, messageBlock) {
const chatId = getCurrentChatId();
export async function hideChatMessage(messageId, _messageBlock) {
return hideChatMessageRange(messageId, messageId, false);
}
if (!chatId || isNaN(messageId)) return;
const message = chat[messageId];
if (!message) return;
message.is_system = false;
messageBlock.attr('is_system', String(false));
// Reload swipes. Useful when a last message is hidden.
hideSwipeButtons();
showSwipeButtons();
saveChatDebounced();
/**
* Mark message as visible (non-system message).
* @deprecated Use hideChatMessageRange.
* @param {number} messageId Message ID
* @param {JQuery<Element>} _messageBlock Unused
* @returns {Promise<void>}
*/
export async function unhideChatMessage(messageId, _messageBlock) {
return hideChatMessageRange(messageId, messageId, true);
}
/**
@ -476,13 +480,13 @@ jQuery(function () {
$(document).on('click', '.mes_hide', async function () {
const messageBlock = $(this).closest('.mes');
const messageId = Number(messageBlock.attr('mesid'));
await hideChatMessage(messageId, messageBlock);
await hideChatMessageRange(messageId, messageId, false);
});
$(document).on('click', '.mes_unhide', async function () {
const messageBlock = $(this).closest('.mes');
const messageId = Number(messageBlock.attr('mesid'));
await unhideChatMessage(messageId, messageBlock);
await hideChatMessageRange(messageId, messageId, true);
});
$(document).on('click', '.mes_file_delete', async function () {

View File

@ -5,16 +5,20 @@ export function showLoader() {
const loader = $('<div></div>').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x');
container.append(loader);
$('body').append(container);
}
export function hideLoader() {
export async function hideLoader() {
//Sets up a 2-step animation. Spinner blurs/fades out, and then the loader shadow does the same.
$('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
//console.log('FADING BLUR SCREEN')
//uncomment this as part of user selection enabling
//$('#loader-spinner')
//comment this instead
$(`#${ELEMENT_ID}`)
//only fade out the spinner and replace with login screen
.animate({ opacity: 0 }, 300, function () {
//console.log('REMOVING LOADER')
//when enabling user select, dont remove the loader container just yet
//comment this out
$(`#${ELEMENT_ID}`).remove();
});
});
@ -25,4 +29,7 @@ export function hideLoader() {
'filter': 'blur(15px)',
'opacity': '0',
});
//uncomment to make user selection live
//await populateUserList()
}

275
public/scripts/login.js Normal file
View File

@ -0,0 +1,275 @@
/**
* CRSF token for requests.
*/
let csrfToken = '';
let discreetLogin = false;
/**
* Gets a CSRF token from the server.
* @returns {Promise<string>} CSRF token
*/
async function getCsrfToken() {
const response = await fetch('/csrf-token');
const data = await response.json();
return data.token;
}
/**
* Gets a list of users from the server.
* @returns {Promise<object>} List of users
*/
async function getUserList() {
const response = await fetch('/api/users/list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
});
if (!response.ok) {
const errorData = await response.json();
return displayError(errorData.error || 'An error occurred');
}
if (response.status === 204) {
discreetLogin = true;
return [];
}
const userListObj = await response.json();
console.log(userListObj);
return userListObj;
}
/**
* Requests a recovery code for the user.
* @param {string} handle User handle
* @returns {Promise<void>}
*/
async function sendRecoveryPart1(handle) {
const response = await fetch('/api/users/recover-step1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ handle }),
});
if (!response.ok) {
const errorData = await response.json();
return displayError(errorData.error || 'An error occurred');
}
showRecoveryBlock();
}
/**
* Sets a new password for the user using the recovery code.
* @param {string} handle User handle
* @param {string} code Recovery code
* @param {string} newPassword New password
* @returns {Promise<void>}
*/
async function sendRecoveryPart2(handle, code, newPassword) {
const recoveryData = {
handle,
code,
newPassword,
};
const response = await fetch('/api/users/recover-step2', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(recoveryData),
});
if (!response.ok) {
const errorData = await response.json();
return displayError(errorData.error || 'An error occurred');
}
console.log(`Successfully recovered password for ${handle}!`);
await performLogin(handle, newPassword);
}
/**
* Attempts to log in the user.
* @param {string} handle User's handle
* @param {string} password User's password
* @returns {Promise<void>}
*/
async function performLogin(handle, password) {
const userInfo = {
handle: handle,
password: password,
};
try {
const response = await fetch('/api/users/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(userInfo),
});
if (!response.ok) {
const errorData = await response.json();
return displayError(errorData.error || 'An error occurred');
}
const data = await response.json();
if (data.handle) {
console.log(`Successfully logged in as ${handle}!`);
redirectToHome();
}
} catch (error) {
console.error('Error logging in:', error);
displayError(String(error));
}
}
/**
* Handles the user selection event.
* @param {object} user User object
* @returns {Promise<void>}
*/
async function onUserSelected(user) {
// No password, just log in
if (!user.password) {
return await performLogin(user.handle, '');
}
$('#passwordRecoveryBlock').hide();
$('#passwordEntryBlock').show();
$('#loginButton').off('click').on('click', async () => {
const password = String($('#userPassword').val());
await performLogin(user.handle, password);
});
$('#recoverPassword').off('click').on('click', async () => {
await sendRecoveryPart1(user.handle);
});
$('#sendRecovery').off('click').on('click', async () => {
const code = String($('#recoveryCode').val());
const newPassword = String($('#newPassword').val());
await sendRecoveryPart2(user.handle, code, newPassword);
});
displayError('');
}
/**
* Displays an error message to the user.
* @param {string} message Error message
*/
function displayError(message) {
$('#errorMessage').text(message);
}
/**
* Redirects the user to the home page.
*/
function redirectToHome() {
window.location.href = '/';
}
/**
* Hides the password entry block and shows the password recovery block.
*/
function showRecoveryBlock() {
$('#passwordEntryBlock').hide();
$('#passwordRecoveryBlock').show();
displayError('');
}
/**
* Hides the password recovery block and shows the password entry block.
*/
function onCancelRecoveryClick() {
$('#passwordRecoveryBlock').hide();
$('#passwordEntryBlock').show();
displayError('');
}
/**
* Configures the login page for normal login.
* @param {import('../../src/users').UserViewModel[]} userList List of users
*/
function configureNormalLogin(userList) {
console.log('Discreet login is disabled');
$('#handleEntryBlock').hide();
$('#normalLoginPrompt').show();
$('#discreetLoginPrompt').hide();
console.log(userList);
for (const user of userList) {
const userBlock = $('<div></div>').addClass('userSelect');
const avatarBlock = $('<div></div>').addClass('avatar');
avatarBlock.append($('<img>').attr('src', user.avatar));
userBlock.append(avatarBlock);
userBlock.append($('<span></span>').text(user.name));
userBlock.append($('<small></small>').text(user.handle));
userBlock.on('click', () => onUserSelected(user));
$('#userList').append(userBlock);
}
}
/**
* Configures the login page for discreet login.
*/
function configureDiscreetLogin() {
console.log('Discreet login is enabled');
$('#handleEntryBlock').show();
$('#normalLoginPrompt').hide();
$('#discreetLoginPrompt').show();
$('#userList').hide();
$('#passwordRecoveryBlock').hide();
$('#passwordEntryBlock').show();
$('#loginButton').off('click').on('click', async () => {
const handle = String($('#userHandle').val());
const password = String($('#userPassword').val());
await performLogin(handle, password);
});
$('#recoverPassword').off('click').on('click', async () => {
const handle = String($('#userHandle').val());
await sendRecoveryPart1(handle);
});
$('#sendRecovery').off('click').on('click', async () => {
const handle = String($('#userHandle').val());
const code = String($('#recoveryCode').val());
const newPassword = String($('#newPassword').val());
await sendRecoveryPart2(handle, code, newPassword);
});
}
(async function () {
csrfToken = await getCsrfToken();
const userList = await getUserList();
if (discreetLogin) {
configureDiscreetLogin();
} else {
configureNormalLogin(userList);
}
document.getElementById('shadow_popup').style.opacity = '';
$('#cancelRecovery').on('click', onCancelRecoveryClick);
$(document).on('keydown', (evt) => {
if (evt.key === 'Enter' && document.activeElement.tagName === 'INPUT') {
if ($('#passwordRecoveryBlock').is(':visible')) {
$('#sendRecovery').trigger('click');
} else {
$('#loginButton').trigger('click');
}
}
});
})();

View File

@ -221,7 +221,7 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
}
if (getGeneratingApi() === 'openai') {
return callPopup(`<h3>Feature unavailable</h3><p>Due to API limitations, rerolling a token is not supported with OpenAI. Try switching to a different API.</p>`, 'text');
return callPopup('<h3>Feature unavailable</h3><p>Due to API limitations, rerolling a token is not supported with OpenAI. Try switching to a different API.</p>', 'text');
}
const { messageLogprobs, continueFrom } = getActiveMessageLogprobData();
@ -261,7 +261,7 @@ function onPrefixClicked() {
function checkGenerateReady() {
if (is_send_press) {
toastr.warning(`Please wait for the current generation to complete.`);
toastr.warning('Please wait for the current generation to complete.');
return false;
}
return true;
@ -407,7 +407,7 @@ export function saveLogprobsForActiveMessage(logprobs, continueFrom) {
messageLogprobs: logprobs,
continueFrom,
hash: getMessageHash(chat[msgId]),
}
};
state.messageLogprobs.set(data.hash, data);
@ -458,7 +458,7 @@ function convertTokenIdLogprobsToText(input) {
// Flatten unique token IDs across all logprobs
const tokenIds = Array.from(new Set(input.flatMap(logprobs =>
logprobs.topLogprobs.map(([token]) => token).concat(logprobs.token)
logprobs.topLogprobs.map(([token]) => token).concat(logprobs.token),
)));
// Submit token IDs to tokenizer to get token text, then build ID->text map
@ -469,7 +469,7 @@ function convertTokenIdLogprobsToText(input) {
input.forEach(logprobs => {
logprobs.token = tokenIdText.get(logprobs.token);
logprobs.topLogprobs = logprobs.topLogprobs.map(([token, logprob]) =>
[tokenIdText.get(token), logprob]
[tokenIdText.get(token), logprob],
);
});
}

View File

@ -2264,7 +2264,7 @@ export class ChatCompletion {
const shouldSquash = (message) => {
return !excludeList.includes(message.identifier) && message.role === 'system' && !message.name;
}
};
if (shouldSquash(message)) {
if (lastMessage && shouldSquash(lastMessage)) {

View File

@ -219,7 +219,7 @@ export function callGenericPopup(text, type, inputValue = '', { okButton, cancel
text,
type,
inputValue,
{ okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling },
{ okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cancelButton },
);
return popup.show();
}

View File

@ -41,7 +41,7 @@ import { SlashCommandParser as NewSlashCommandParser } from './slash-commands/Sl
import { SlashCommandParserError } from './slash-commands/SlashCommandParserError.js';
import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
import { getMessageTimeStamp } from './RossAscends-mods.js';
import { hideChatMessage, unhideChatMessage } from './chats.js';
import { hideChatMessageRange } from './chats.js';
import { getContext, saveMetadataDebounced } from './extensions.js';
import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
import { findGroupMemberId, groups, is_group_generating, openGroupById, resetSelectedGroup, saveGroupChat, selected_group } from './group-chats.js';
@ -937,16 +937,7 @@ async function hideMessageCallback(_, arg) {
return;
}
for (let messageId = range.start; messageId <= range.end; messageId++) {
const messageBlock = $(`.mes[mesid="${messageId}"]`);
if (!messageBlock.length) {
console.warn(`WARN: No message found with ID ${messageId}`);
return;
}
await hideChatMessage(messageId, messageBlock);
}
await hideChatMessageRange(range.start, range.end, false);
}
async function unhideMessageCallback(_, arg) {
@ -962,17 +953,7 @@ async function unhideMessageCallback(_, arg) {
return '';
}
for (let messageId = range.start; messageId <= range.end; messageId++) {
const messageBlock = $(`.mes[mesid="${messageId}"]`);
if (!messageBlock.length) {
console.warn(`WARN: No message found with ID ${messageId}`);
return '';
}
await unhideChatMessage(messageId, messageBlock);
}
await hideChatMessageRange(range.start, range.end, true);
return '';
}

View File

@ -0,0 +1,104 @@
<div class="adminTabs wide100p">
<nav class="adminNav flex-container alignItemsCenter justifyCenter">
<button type="button" class="manageUsersButton menu_button menu_button_icon" data-target-tab="usersList">
<h4 data-i18n="Manager Users">Manage Users</h4>
</button>
<button type="button" class="newUserButton menu_button menu_button_icon" data-target-tab="registerNewUserBlock">
<h4 data-i18n="New User">New User</h4>
</button>
</nav>
<div class="userAccountTemplate template_element">
<div class="flex-container userAccount alignItemsCenter flexGap10">
<div>
<div class="avatar">
<img src="img/ai4.png" alt="avatar">
</div>
</div>
<div class="flex1 flex-container flexFlowColumn flexNoGap justifyLeft">
<div class="flex-container flexGap10 alignItemsCenter">
<i class="hasPassword fa-solid fa-lock" title="This account is password protected."></i>
<i class="noPassword fa-solid fa-lock-open" title="This account is not password protected."></i>
<h3 class="userName margin0"></h3>
<small class="userHandle">&nbsp;</small>
</div>
<div class="flex-container flexFlowColumn flexNoGap">
<span>
<span data-i18n="Role:">Role:</span>
<span class="userRole"></span>
</span>
<span>
<span data-i18n="Status:">Status:</span>
<span class="userStatus">&nbsp;</span>
</span>
<span>
<span data-i18n="Created:">Created:</span>
<span class="userCreated">&nbsp;</span>
</span>
</div>
</div>
<div class="flex-container flexFlowColumn">
<div class="flex-container">
<div class="userChangeNameButton menu_button" title="Change user display name.">
<i class="fa-fw fa-solid fa-pencil"></i>
</div>
<div class="userEnableButton menu_button" title="Enable user account.">
<i class="fa-fw fa-solid fa-check"></i>
</div>
<div class="userDisableButton menu_button" title="Disable user account.">
<i class="fa-fw fa-solid fa-ban"></i>
</div>
<div class="userPromoteButton menu_button" title="Promote user to admin.">
<i class="fa-fw fa-solid fa-arrow-up"></i>
</div>
<div class="userDemoteButton menu_button" title="Demote user to regular user.">
<i class="fa-fw fa-solid fa-arrow-down"></i>
</div>
</div>
<div class="flex-container">
<div class="userBackupButton menu_button menu_button_icon" title="Download a backup of user data.">
<i class="fa-fw fa-solid fa-download"></i>
</div>
<div class="userChangePasswordButton menu_button" title="Change user password.">
<i class="fa-fw fa-solid fa-key"></i>
</div>
<div class="userDelete menu_button warning" title="Delete user account.">
<i class="fa-fw fa-solid fa-trash"></i>
</div>
</div>
</div>
</div>
</div>
<div class="navTab usersList flex-container flexFlowColumn">
</div>
<div class="navTab registerNewUserBlock" style="display: none;">
<form class="flex-container flexFlowColumn flexGap10 userCreateForm" action="javascript:void(0);">
<div class="flex-container flexNoGap">
<span data-i18n="Display Name:">Display Name:</span>
<span class="warning">*</span>
<input name="_name" class="createUserDisplayName text_pole" type="text" placeholder="e.g. John Snow" autocomplete="username">
</div>
<div class="flex-container flexNoGap">
<span data-i18n="User Handle:">User Handle:</span>
<span class="warning">*</span>
<input name="handle" class="createUserHandle text_pole" placeholder="e.g. john-snow (lowercase letters, numbers, and dashes only)" type="text" pattern="[a-z0-9-]+">
</div>
<div class="flex-container flexNoGap">
<span data-i18n="Password:">Password:</span>
<input name="password" class="createUserPassword text_pole" type="password" placeholder="[ No password ]" autocomplete="new-password">
</div>
<div class="flex-container flexNoGap">
<span data-i18n="Confirm Password:">Confirm Password:</span>
<input name="confirm" class="createUserConfirmPassword text_pole" type="password" placeholder="[ No password ]" autocomplete="new-password">
</div>
<span data-i18n="This will create a new subfolder...">
This will create a new subfolder in the /data/ directory with the user's handle as the folder name.
</span>
<div class="flex-container justifyCenter">
<button type="submit" class="menu_button menu_button_icon newUserRegisterFinalizeButton">
<i class="fa-fw fa-solid fa-user-plus"></i>
<span data-i18n="Create">Create</span>
</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,5 @@
<div class="flex-container">
<h3>
Enter a new display name:
</h3>
</div>

View File

@ -0,0 +1,14 @@
<form action="javascript:void(0);" class="flex-container flexFlowColumn">
<div class="currentPasswordBlock">
<label data-i18n="Current Password:" for="user">Current Password:</label>
<input type="password" name="current" class="text_pole" placeholder="[ No password ]" autocomplete="current-password">
</div>
<div class="newPasswordBlock">
<label data-i18n="New Password:" for="password">New Password:</label>
<input type="password" name="password" class="text_pole" placeholder="[ No password ]" autocomplete="new-password">
</div>
<div class="confirmPasswordBlock">
<label data-i18n="Confirm New Password:" for="confirm">Confirm New Password:</label>
<input type="password" name="confirm" class="text_pole" placeholder="[ No password ]" autocomplete="new-password">
</div>
</form>

View File

@ -0,0 +1,26 @@
<div class="flex-container flexFlowColumn">
<h3 data-i18n="Are you sure you want to delete this user?">
Are you sure you want to delete this user?
</h3>
<div>
<span data-i18n="Deleting:">Deleting:</span>
<strong id="deleteUserName"></strong>
</div>
<label class="checkbox_label justifyCenter" for="deleteUserData">
<input id="deleteUserData" name="deleteUserData" type="checkbox">
<span data-i18n="Also wipe user data.">Also wipe user data.</span>
</label>
<hr>
<div>
<strong data-i18n="Warning:">Warning:</strong>
<span data-i18n="This action is irreversible.">This action is irreversible.</span>
</div>
<div>
<label for="deleteUserHandle">
<strong data-i18n="Type the user's handle below to confirm:">
Type the user's handle below to confirm:
</strong>
</label>
<input id="deleteUserHandle" name="deleteUserHandle" type="text" class="text_pole" placeholder="[ Type here ]">
</div>
</div>

View File

@ -0,0 +1,13 @@
<div class="flex-container flexFlowColumn marginBot10">
<h3 data-i18n="Are you sure you want to reset your settings to factory defaults?">
Are you sure you want to reset your settings to factory defaults?
</h3>
<div data-i18n="Don't forget to save a snapshot of your settings before proceeding.">
Don't forget to save a snapshot of your settings before proceeding.
</div>
<hr>
<div>
Enter your password below to confirm:
</div>
<input id="resetSettingsPassword" name="password" type="password" class="text_pole" placeholder="Password">
</div>

View File

@ -0,0 +1,31 @@
<div class="padding5">
<h3 class="title_restorable">
<span data-i18n="Settings Snapshots">Settings Snapshots</span>
<div class="makeSnapshotButton menu_button menu_button_icon" title="Record a snapshot of your current settings.">
<i class="fa-fw fa-solid fa-camera"></i>
<span data-i18n="Make a Snapshot">Make a Snapshot</span>
</div>
</h3>
<hr>
<div class="snapshotList flex-container flexFlowColumn">
</div>
<div class="template_element snapshotTemplate">
<div class="snapshot inline-drawer wide100p">
<div class="inline-drawer-toggle inline-drawer-header flexGap10">
<div class="flex-container flexFlowColumn flexNoGap justifyLeft">
<span class="snapshotName"></span>
<div class="flex-container flexGap10">
<small class="snapshotDate"></small>
<small>(<span class="snapshotSize"></span>)</small>
</div>
</div>
<div class="expander"></div>
<div class="menu_button fa-solid fa-recycle snapshotRestoreButton" title="Restore this snapshot"></div>
<div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
</div>
<div class="inline-drawer-content">
<textarea class="text_pole textarea_compact fontsize80p snapshotContent" readonly placeholder="Loading..." rows="25"></textarea>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,86 @@
<div class="flex-container flexFlowColumn justifyLeft flexGap10">
<div>
<h2 class="marginBot10 flex-container">
<span>Hi,</span><span class="userName margin0"></span>
<div data-require-accounts class="userChangeNameButton right_menu_button" title="Change display name.">
<i class="fa-fw fa-solid fa-pencil fa-xs"></i>
</div>
</h2>
<div class="accountsDisabledHint" style="display: none;">
To enable multi-account features, restart the SillyTavern server with <code>enableUserAccounts</code> set to true in the config.yaml file.
</div>
</div>
<div>
<h3>
Account Info
</h3>
<div class="flex-container flexGap10">
<div>
<div class="avatar" title="To change your user avatar, select a default persona in the Persona Management menu.">
<img src="img/ai4.png" alt="avatar">
</div>
</div>
<div class="flex1 flex-container flexGap10">
<div class="flex-container flexFlowColumn">
<div>
<span data-i18n="Handle:">Handle:</span>
<span class="userHandle"></span>
</div>
<div>
<span data-i18n="Role:">Role:</span>
<span class="userRole"></span>
</div>
</div>
<div class="flex-container flexFlowColumn">
<div>
<span data-i18n="Created:">Created:</span>
<span class="userCreated"></span>
</div>
<div>
<span data-i18n="Password:">Password:</span>
<i class="hasPassword fa-fw fa-solid fa-lock" title="This account is password protected."></i>
<i class="noPassword fa-fw fa-solid fa-lock-open" title="This account is not password protected."></i>
</div>
</div>
</div>
</div>
</div>
<div>
<h3>
Account Actions
</h3>
<div class="flex-container flexFlowColumn flexNoGap">
<div data-require-accounts class="flex-container">
<div class="userChangePasswordButton menu_button menu_button_icon" title="Change your password.">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Change Password">Change Password</span>
</div>
</div>
<div class="flex-container">
<div class="userSettingsSnapshotsButton menu_button menu_button_icon" title="Manage your settings snapshots.">
<i class="fa-fw fa-solid fa-camera"></i>
<span data-i18n="Settings Snapshots">Settings Snapshots</span>
</div>
<div class="userBackupButton menu_button menu_button_icon" title="Download a complete backup of your user data.">
<i class="fa-fw fa-solid fa-download"></i>
<span data-i18n="Download Backup">Download Backup</span>
</div>
</div>
</div>
</div>
<div>
<h3>
Danger Zone
</h3>
<div class="flex-container">
<div class="userResetSettingsButton menu_button menu_button_icon" title="Reset your settings to factory defaults.">
<i class="fa-fw fa-solid fa-cog warning"></i>
<span data-i18n="Reset Settings">Reset Settings</span>
</div>
<div class="userResetAllButton menu_button menu_button_icon" title="Wipe all user data and reset your account to factory settings.">
<i class="fa-fw fa-solid fa-skull warning"></i>
<span data-i18n="Reset Everything">Reset Everything</span>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,18 @@
<form action="javascript:void(0);" class="flex-container flexFlowColumn">
<h3 class="neutral_warning">
This will delete all your settings and data. There will be no undo button.
Make sure you have a backup before proceeding.
</h3>
<hr>
<div>
Account reset code has been posted to the server console.
</div>
<div class="currentPasswordBlock">
<label data-i18n="Current Password:" for="user">Current Password:</label>
<input type="password" name="password" class="text_pole" placeholder="[ No password ]" autocomplete="current-password">
</div>
<div class="resetCodeBlock">
<label data-i18n="Reset Code:" for="password">Reset Code:</label>
<input type="text" name="code" class="text_pole" placeholder="XXXX">
</div>
</form>

808
public/scripts/user.js Normal file
View File

@ -0,0 +1,808 @@
import { getRequestHeaders } from '../script.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
import { renderTemplateAsync } from './templates.js';
import { humanFileSize } from './utils.js';
/**
* @type {import('../../src/users.js').UserViewModel} Logged in user
*/
export let currentUser = null;
export let accountsEnabled = false;
/**
* Enable or disable user account controls in the UI.
* @param {boolean} isEnabled User account controls enabled
* @returns {Promise<void>}
*/
export async function setUserControls(isEnabled) {
accountsEnabled = isEnabled;
if (!isEnabled) {
$('#logout_button').hide();
$('#admin_button').hide();
return;
}
$('#logout_button').show();
await getCurrentUser();
}
/**
* Check if the current user is an admin.
* @returns {boolean} True if the current user is an admin
*/
function isAdmin() {
if (!currentUser) {
return false;
}
return Boolean(currentUser.admin);
}
/**
* Get the current user.
* @returns {Promise<void>}
*/
async function getCurrentUser() {
try {
const response = await fetch('/api/users/me', {
headers: getRequestHeaders(),
});
if (!response.ok) {
throw new Error('Failed to get current user');
}
currentUser = await response.json();
$('#admin_button').toggle(accountsEnabled && isAdmin());
} catch (error) {
console.error('Error getting current user:', error);
}
}
/**
* Get a list of all users.
* @returns {Promise<import('../../src/users.js').UserViewModel[]>} Users
*/
async function getUsers() {
try {
const response = await fetch('/api/users/get', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!response.ok) {
throw new Error('Failed to get users');
}
return response.json();
} catch (error) {
console.error('Error getting users:', error);
}
}
/**
* Enable a user account.
* @param {string} handle User handle
* @param {function} callback Success callback
* @returns {Promise<void>}
*/
async function enableUser(handle, callback) {
try {
const response = await fetch('/api/users/enable', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to enable user');
throw new Error('Failed to enable user');
}
callback();
} catch (error) {
console.error('Error enabling user:', error);
}
}
async function disableUser(handle, callback) {
try {
const response = await fetch('/api/users/disable', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data?.error || 'Unknown error', 'Failed to disable user');
throw new Error('Failed to disable user');
}
callback();
} catch (error) {
console.error('Error disabling user:', error);
}
}
/**
* Promote a user to admin.
* @param {string} handle User handle
* @param {function} callback Success callback
* @returns {Promise<void>}
*/
async function promoteUser(handle, callback) {
try {
const response = await fetch('/api/users/promote', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to promote user');
throw new Error('Failed to promote user');
}
callback();
} catch (error) {
console.error('Error promoting user:', error);
}
}
/**
* Demote a user from admin.
* @param {string} handle User handle
* @param {function} callback Success callback
*/
async function demoteUser(handle, callback) {
try {
const response = await fetch('/api/users/demote', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to demote user');
throw new Error('Failed to demote user');
}
callback();
} catch (error) {
console.error('Error demoting user:', error);
}
}
/**
* Create a new user.
* @param {HTMLFormElement} form Form element
*/
async function createUser(form, callback) {
const errors = [];
const formData = new FormData(form);
if (!formData.get('handle')) {
errors.push('Handle is required');
}
if (formData.get('password') !== formData.get('confirm')) {
errors.push('Passwords do not match');
}
if (errors.length) {
toastr.error(errors.join(', '), 'Failed to create user');
return;
}
const body = {};
formData.forEach(function (value, key) {
if (key === 'confirm') {
return;
}
if (key.startsWith('_')) {
key = key.substring(1);
}
body[key] = value;
});
try {
const response = await fetch('/api/users/create', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(body),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to create user');
throw new Error('Failed to create user');
}
form.reset();
callback();
} catch (error) {
console.error('Error creating user:', error);
}
}
/**
* Backup a user's data.
* @param {string} handle Handle of the user to backup
* @param {function} callback Success callback
* @returns {Promise<void>}
*/
async function backupUserData(handle, callback) {
try {
toastr.info('Please wait for the download to start.', 'Backup Requested');
const response = await fetch('/api/users/backup', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to backup user data');
throw new Error('Failed to backup user data');
}
const blob = await response.blob();
const header = response.headers.get('Content-Disposition');
const parts = header.split(';');
const filename = parts[1].split('=')[1].replaceAll('"', '');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
callback();
} catch (error) {
console.error('Error backing up user data:', error);
}
}
/**
* Shows a popup to change a user's password.
* @param {string} handle User handle
* @param {function} callback Success callback
*/
async function changePassword(handle, callback) {
try {
const template = $(await renderTemplateAsync('changePassword'));
template.find('.currentPasswordBlock').toggle(!isAdmin());
let newPassword = '';
let confirmPassword = '';
let oldPassword = '';
template.find('input[name="current"]').on('input', function () {
oldPassword = String($(this).val());
});
template.find('input[name="password"]').on('input', function () {
newPassword = String($(this).val());
});
template.find('input[name="confirm"]').on('input', function () {
confirmPassword = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Change', cancelButton: 'Cancel', wide: false, large: false });
if (result === POPUP_RESULT.CANCELLED || result === POPUP_RESULT.NEGATIVE) {
throw new Error('Change password cancelled');
}
if (newPassword !== confirmPassword) {
toastr.error('Passwords do not match', 'Failed to change password');
throw new Error('Passwords do not match');
}
const response = await fetch('/api/users/change-password', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle, newPassword, oldPassword }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to change password');
throw new Error('Failed to change password');
}
toastr.success('Password changed successfully', 'Password Changed');
callback();
}
catch (error) {
console.error('Error changing password:', error);
}
}
/**
* Delete a user.
* @param {string} handle User handle
* @param {function} callback Success callback
*/
async function deleteUser(handle, callback) {
try {
if (handle === currentUser.handle) {
toastr.error('Cannot delete yourself', 'Failed to delete user');
throw new Error('Cannot delete yourself');
}
let purge = false;
let confirmHandle = '';
const template = $(await renderTemplateAsync('deleteUser'));
template.find('#deleteUserName').text(handle);
template.find('input[name="deleteUserData"]').on('input', function () {
purge = $(this).is(':checked');
});
template.find('input[name="deleteUserHandle"]').on('input', function () {
confirmHandle = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel', wide: false, large: false });
if (result !== POPUP_RESULT.AFFIRMATIVE) {
throw new Error('Delete user cancelled');
}
if (handle !== confirmHandle) {
toastr.error('Handles do not match', 'Failed to delete user');
throw new Error('Handles do not match');
}
const response = await fetch('/api/users/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle, purge }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to delete user');
throw new Error('Failed to delete user');
}
toastr.success('User deleted successfully', 'User Deleted');
callback();
} catch (error) {
console.error('Error deleting user:', error);
}
}
/**
* Reset a user's settings.
* @param {string} handle User handle
* @param {function} callback Success callback
*/
async function resetSettings(handle, callback) {
try {
let password = '';
const template = $(await renderTemplateAsync('resetSettings'));
template.find('input[name="password"]').on('input', function () {
password = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Reset', cancelButton: 'Cancel', wide: false, large: false });
if (result !== POPUP_RESULT.AFFIRMATIVE) {
throw new Error('Reset settings cancelled');
}
const response = await fetch('/api/users/reset-settings', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle, password }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to reset settings');
throw new Error('Failed to reset settings');
}
toastr.success('Settings reset successfully', 'Settings Reset');
callback();
} catch (error) {
console.error('Error resetting settings:', error);
}
}
/**
* Change a user's display name.
* @param {string} handle User handle
* @param {string} name Current name
* @param {function} callback Success callback
*/
async function changeName(handle, name, callback) {
try {
const template = $(await renderTemplateAsync('changeName'));
const result = await callGenericPopup(template, POPUP_TYPE.INPUT, name, { okButton: 'Change', cancelButton: 'Cancel', wide: false, large: false });
if (!result) {
throw new Error('Change name cancelled');
}
name = String(result);
const response = await fetch('/api/users/change-name', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle, name }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to change name');
throw new Error('Failed to change name');
}
toastr.success('Name changed successfully', 'Name Changed');
callback();
} catch (error) {
console.error('Error changing name:', error);
}
}
/**
* Restore a settings snapshot.
* @param {string} name Snapshot name
* @param {function} callback Success callback
*/
async function restoreSnapshot(name, callback) {
try {
const confirm = await callGenericPopup(
`Are you sure you want to restore the settings from "${name}"?`,
POPUP_TYPE.CONFIRM,
'',
{ okButton: 'Restore', cancelButton: 'Cancel', wide: false, large: false },
);
if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
throw new Error('Restore snapshot cancelled');
}
const response = await fetch('/api/settings/restore-snapshot', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to restore snapshot');
throw new Error('Failed to restore snapshot');
}
callback();
} catch (error) {
console.error('Error restoring snapshot:', error);
}
}
/**
* Load the content of a settings snapshot.
* @param {string} name Snapshot name
* @returns {Promise<string>} Snapshot content
*/
async function loadSnapshotContent(name) {
try {
const response = await fetch('/api/settings/load-snapshot', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to load snapshot content');
throw new Error('Failed to load snapshot content');
}
return response.text();
} catch (error) {
console.error('Error loading snapshot content:', error);
}
}
/**
* Gets a list of settings snapshots.
* @returns {Promise<Snapshot[]>} List of snapshots
* @typedef {Object} Snapshot
* @property {string} name Snapshot name
* @property {number} date Date in milliseconds
* @property {number} size File size in bytes
*/
async function getSnapshots() {
try {
const response = await fetch('/api/settings/get-snapshots', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to get settings snapshots');
throw new Error('Failed to get settings snapshots');
}
const snapshots = await response.json();
return snapshots;
} catch (error) {
console.error('Error getting settings snapshots:', error);
return [];
}
}
/**
* Make a snapshot of the current settings.
* @param {function} callback Success callback
* @returns {Promise<void>}
*/
async function makeSnapshot(callback) {
try {
const response = await fetch('/api/settings/make-snapshot', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to make snapshot');
throw new Error('Failed to make snapshot');
}
toastr.success('Snapshot created successfully', 'Snapshot Created');
callback();
} catch (error) {
console.error('Error making snapshot:', error);
}
}
/**
* Open the settings snapshots view.
*/
async function viewSettingsSnapshots() {
const template = $(await renderTemplateAsync('snapshotsView'));
async function renderSnapshots() {
const snapshots = await getSnapshots();
template.find('.snapshotList').empty();
for (const snapshot of snapshots.sort((a, b) => b.date - a.date)) {
const snapshotBlock = template.find('.snapshotTemplate .snapshot').clone();
snapshotBlock.find('.snapshotName').text(snapshot.name);
snapshotBlock.find('.snapshotDate').text(new Date(snapshot.date).toLocaleString());
snapshotBlock.find('.snapshotSize').text(humanFileSize(snapshot.size));
snapshotBlock.find('.snapshotRestoreButton').on('click', async (e) => {
e.stopPropagation();
restoreSnapshot(snapshot.name, () => location.reload());
});
snapshotBlock.find('.inline-drawer-toggle').on('click', async () => {
const contentBlock = snapshotBlock.find('.snapshotContent');
if (!contentBlock.val()) {
const content = await loadSnapshotContent(snapshot.name);
contentBlock.val(content);
}
});
template.find('.snapshotList').append(snapshotBlock);
}
}
callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false });
template.find('.makeSnapshotButton').on('click', () => makeSnapshot(renderSnapshots));
renderSnapshots();
}
/**
* Reset everything to default.
* @param {function} callback Success callback
*/
async function resetEverything(callback) {
try {
const step1Response = await fetch('/api/users/reset-step1', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!step1Response.ok) {
const data = await step1Response.json();
toastr.error(data.error || 'Unknown error', 'Failed to reset');
throw new Error('Failed to reset everything');
}
let password = '';
let code = '';
const template = $(await renderTemplateAsync('userReset'));
template.find('input[name="password"]').on('input', function () {
password = String($(this).val());
});
template.find('input[name="code"]').on('input', function () {
code = String($(this).val());
});
const confirm = await callGenericPopup(
template,
POPUP_TYPE.CONFIRM,
'',
{ okButton: 'Reset', cancelButton: 'Cancel', wide: false, large: false },
);
if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
throw new Error('Reset everything cancelled');
}
const step2Response = await fetch('/api/users/reset-step2', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ password, code }),
});
if (!step2Response.ok) {
const data = await step2Response.json();
toastr.error(data.error || 'Unknown error', 'Failed to reset');
throw new Error('Failed to reset everything');
}
toastr.success('Everything reset successfully', 'Reset Everything');
callback();
} catch (error) {
console.error('Error resetting everything:', error);
}
}
async function openUserProfile() {
await getCurrentUser();
const template = $(await renderTemplateAsync('userProfile'));
template.find('.userName').text(currentUser.name);
template.find('.userHandle').text(currentUser.handle);
template.find('.avatar img').attr('src', currentUser.avatar);
template.find('.userRole').text(currentUser.admin ? 'Admin' : 'User');
template.find('.userCreated').text(new Date(currentUser.created).toLocaleString());
template.find('.hasPassword').toggle(currentUser.password);
template.find('.noPassword').toggle(!currentUser.password);
template.find('.userSettingsSnapshotsButton').on('click', () => viewSettingsSnapshots());
template.find('.userChangeNameButton').on('click', async () => changeName(currentUser.handle, currentUser.name, async () => {
await getCurrentUser();
template.find('.userName').text(currentUser.name);
}));
template.find('.userChangePasswordButton').on('click', () => changePassword(currentUser.handle, async () => {
await getCurrentUser();
template.find('.hasPassword').toggle(currentUser.password);
template.find('.noPassword').toggle(!currentUser.password);
}));
template.find('.userBackupButton').on('click', function () {
$(this).addClass('disabled');
backupUserData(currentUser.handle, () => {
$(this).removeClass('disabled');
});
});
template.find('.userResetSettingsButton').on('click', () => resetSettings(currentUser.handle, () => location.reload()));
template.find('.userResetAllButton').on('click', () => resetEverything(() => location.reload()));
if (!accountsEnabled) {
template.find('[data-require-accounts]').hide();
template.find('.accountsDisabledHint').show();
}
const popupOptions = {
okButton: 'Close',
wide: false,
large: false,
allowVerticalScrolling: true,
allowHorizontalScrolling: false,
};
callGenericPopup(template, POPUP_TYPE.TEXT, '', popupOptions);
}
async function openAdminPanel() {
async function renderUsers() {
const users = await getUsers();
template.find('.usersList').empty();
for (const user of users) {
const userBlock = template.find('.userAccountTemplate .userAccount').clone();
userBlock.find('.userName').text(user.name);
userBlock.find('.userHandle').text(user.handle);
userBlock.find('.userStatus').text(user.enabled ? 'Enabled' : 'Disabled');
userBlock.find('.userRole').text(user.admin ? 'Admin' : 'User');
userBlock.find('.avatar img').attr('src', user.avatar);
userBlock.find('.hasPassword').toggle(user.password);
userBlock.find('.noPassword').toggle(!user.password);
userBlock.find('.userCreated').text(new Date(user.created).toLocaleString());
userBlock.find('.userEnableButton').toggle(!user.enabled).on('click', () => enableUser(user.handle, renderUsers));
userBlock.find('.userDisableButton').toggle(user.enabled).on('click', () => disableUser(user.handle, renderUsers));
userBlock.find('.userPromoteButton').toggle(!user.admin).on('click', () => promoteUser(user.handle, renderUsers));
userBlock.find('.userDemoteButton').toggle(user.admin).on('click', () => demoteUser(user.handle, renderUsers));
userBlock.find('.userChangePasswordButton').on('click', () => changePassword(user.handle, renderUsers));
userBlock.find('.userDelete').on('click', () => deleteUser(user.handle, renderUsers));
userBlock.find('.userChangeNameButton').on('click', async () => changeName(user.handle, user.name, renderUsers));
userBlock.find('.userBackupButton').on('click', function () {
$(this).addClass('disabled').off('click');
backupUserData(user.handle, renderUsers);
});
template.find('.usersList').append(userBlock);
}
}
const template = $(await renderTemplateAsync('admin'));
template.find('.adminNav > button').on('click', function () {
const target = String($(this).data('target-tab'));
template.find('.navTab').each(function () {
$(this).toggle(this.classList.contains(target));
});
});
template.find('.createUserDisplayName').on('input', async function () {
const slug = await slugify(String($(this).val()));
template.find('.createUserHandle').val(slug);
});
template.find('.userCreateForm').on('submit', function (event) {
if (!(event.target instanceof HTMLFormElement)) {
return;
}
event.preventDefault();
createUser(event.target, () => {
template.find('.manageUsersButton').trigger('click');
renderUsers();
});
});
callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false, allowVerticalScrolling: true, allowHorizontalScrolling: false });
renderUsers();
}
/**
* Log out the current user.
* @returns {Promise<void>}
*/
async function logout() {
await fetch('/api/users/logout', {
method: 'POST',
headers: getRequestHeaders(),
});
window.location.reload();
}
/**
* Runs a text through the slugify API endpoint.
* @param {string} text Text to slugify
* @returns {Promise<string>} Slugified text
*/
async function slugify(text) {
try {
const response = await fetch('/api/users/slugify', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ text }),
});
if (!response.ok) {
throw new Error('Failed to slugify text');
}
return response.text();
} catch (error) {
console.error('Error slugifying text:', error);
return text;
}
}
jQuery(() => {
$('#logout_button').on('click', () => {
logout();
});
$('#admin_button').on('click', () => {
openAdminPanel();
});
$('#account_button').on('click', () => {
openUserProfile();
});
});

View File

@ -5,6 +5,7 @@
@import url(css/character-group-overlay.css);
@import url(css/file-form.css);
@import url(css/logprobs.css);
@import url(css/accounts.css);
:root {
--doc-height: 100%;
@ -456,7 +457,7 @@ body.reduced-motion #bg_custom {
}
#bg1 {
background-image: url('backgrounds/__transparent.png');
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=');
z-index: -3;
}
@ -3498,7 +3499,7 @@ a {
}
#ui_language_select {
width: 10em;
width: 8em;
}
#extensions_settings .inline-drawer-toggle.inline-drawer-header:hover,

View File

View File

@ -1,21 +0,0 @@
{
"name": "Default (Dark) 1.7.1",
"blur_strength": 10,
"main_text_color": "rgba(220, 220, 210, 1)",
"italics_text_color": "rgba(145, 145, 145, 1)",
"quote_text_color": "rgba(225, 138, 36, 1)",
"blur_tint_color": "rgba(23, 23, 23, 1)",
"user_mes_blur_tint_color": "rgba(0, 0, 0, 0.9)",
"bot_mes_blur_tint_color": "rgba(0, 0, 0, 0.9)",
"shadow_color": "rgba(0, 0, 0, 1)",
"shadow_width": 2,
"font_scale": 1,
"fast_ui_mode": false,
"waifuMode": false,
"avatar_style": 0,
"chat_display": 0,
"noShadows": true,
"sheld_width": 0,
"timer_enabled": false,
"hotswap_enabled": true
}

View File

@ -1,21 +0,0 @@
{
"name": "Ross v2",
"blur_strength": 10,
"main_text_color": "rgba(230, 230, 220, 1)",
"italics_text_color": "rgba(145, 145, 145, 1)",
"quote_text_color": "rgba(73, 179, 255, 0.91)",
"blur_tint_color": "rgba(0, 0, 0, 0.5)",
"user_mes_blur_tint_color": "rgba(51, 51, 51, 0.2)",
"bot_mes_blur_tint_color": "rgba(97, 97, 97, 0.43)",
"shadow_color": "rgba(0, 0, 0, 0.5)",
"shadow_width": 2,
"font_scale": 0.95,
"fast_ui_mode": false,
"waifuMode": false,
"avatar_style": 1,
"chat_display": 1,
"noShadows": false,
"sheld_width": 1,
"timer_enabled": true,
"hotswap_enabled": true
}

View File

View File

@ -1 +0,0 @@
# Put World Info / Lorebook JSON files here

62
recover.js Normal file
View File

@ -0,0 +1,62 @@
const yaml = require('yaml');
const fs = require('fs');
const storage = require('node-persist');
const users = require('./src/users');
const userAccount = process.argv[2];
const userPassword = process.argv[3];
if (!userAccount) {
console.error('A tool for recovering lost SillyTavern accounts. Uses a "dataRoot" setting from config.yaml file.');
console.error('Usage: node recover.js [account] (password)');
console.error('Example: node recover.js admin password');
process.exit(1);
}
async function initStorage() {
const config = yaml.parse(fs.readFileSync('config.yaml', 'utf8'));
const dataRoot = config.dataRoot;
if (!dataRoot) {
console.error('No "dataRoot" setting found in config.yaml file.');
process.exit(1);
}
await users.initUserStorage(dataRoot);
}
async function main() {
await initStorage();
/**
* @type {import('./src/users').User}
*/
const user = await storage.get(users.toKey(userAccount));
if (!user) {
console.error(`User "${userAccount}" not found.`);
process.exit(1);
}
if (!user.enabled) {
console.log('User is disabled. Enabling...');
user.enabled = true;
}
if (userPassword) {
console.log('Setting new password...');
const salt = users.getPasswordSalt();
const passwordHash = users.getPasswordHash(userPassword, salt);
user.password = passwordHash;
user.salt = salt;
} else {
console.log('Setting an empty password...');
user.password = '';
user.salt = '';
}
await storage.setItem(users.toKey(userAccount), user);
console.log('User recovered. A program will exit now.');
}
main();

186
server.js
View File

@ -1,7 +1,6 @@
#!/usr/bin/env node
// native node modules
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const https = require('https');
@ -19,8 +18,10 @@ const doubleCsrf = require('csrf-csrf').doubleCsrf;
const express = require('express');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const cookieSession = require('cookie-session');
const multer = require('multer');
const responseTime = require('response-time');
const helmet = require('helmet').default;
// net related library imports
const net = require('net');
@ -33,6 +34,7 @@ util.inspect.defaultOptions.maxStringLength = null;
util.inspect.defaultOptions.depth = 4;
// local library imports
const userModule = require('./src/users');
const basicAuthMiddleware = require('./src/middleware/basicAuth');
const whitelistMiddleware = require('./src/middleware/whitelist');
const contentManager = require('./src/endpoints/content-manager');
@ -60,6 +62,7 @@ const DEFAULT_PORT = 8000;
const DEFAULT_AUTORUN = false;
const DEFAULT_LISTEN = false;
const DEFAULT_CORS_PROXY = false;
const DEFAULT_WHITELIST = true;
const cliArguments = yargs(hideBin(process.argv))
.usage('Usage: <your-start-script> <command> [options]')
@ -95,6 +98,14 @@ const cliArguments = yargs(hideBin(process.argv))
type: 'string',
default: 'certs/privkey.pem',
describe: 'Path to your private key file.',
}).option('whitelist', {
type: 'boolean',
default: null,
describe: 'Enables whitelist mode',
}).option('dataRoot', {
type: 'string',
default: null,
describe: 'Root directory for data storage',
}).parseSync();
// change all relative paths
@ -103,6 +114,9 @@ const serverDirectory = __dirname;
process.chdir(serverDirectory);
const app = express();
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(compression());
app.use(responseTime());
@ -110,9 +124,12 @@ const server_port = cliArguments.port ?? process.env.SILLY_TAVERN_PORT ?? getCon
const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN)) && !cliArguments.ssl;
const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN);
const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY);
const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST);
const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data');
const basicAuthMode = getConfigValue('basicAuthMode', false);
const enableAccounts = getConfigValue('enableUserAccounts', false);
const { DIRECTORIES, UPLOADS_PATH } = require('./src/constants');
const { UPLOADS_PATH } = require('./src/constants');
// CORS Settings //
const CORS = cors({
@ -124,41 +141,7 @@ app.use(CORS);
if (listen && basicAuthMode) app.use(basicAuthMiddleware);
app.use(whitelistMiddleware(listen));
// CSRF Protection //
if (!cliArguments.disableCsrf) {
const CSRF_SECRET = crypto.randomBytes(8).toString('hex');
const COOKIES_SECRET = crypto.randomBytes(8).toString('hex');
const { generateToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => CSRF_SECRET,
cookieName: 'X-CSRF-Token',
cookieOptions: {
httpOnly: true,
sameSite: 'strict',
secure: false,
},
size: 64,
getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
app.get('/csrf-token', (req, res) => {
res.json({
'token': generateToken(res, req),
});
});
app.use(cookieParser(COOKIES_SECRET));
app.use(doubleCsrfProtection);
} else {
console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
app.get('/csrf-token', (req, res) => {
res.json({
'token': 'disabled',
});
});
}
app.use(whitelistMiddleware(enableWhitelist, listen));
if (enableCorsProxy) {
const bodyParser = require('body-parser');
@ -210,34 +193,94 @@ if (enableCorsProxy) {
});
}
app.use(cookieSession({
name: userModule.getCookieSessionName(),
sameSite: 'strict',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
secret: userModule.getCookieSecret(),
}));
app.use(userModule.setUserDataMiddleware);
// CSRF Protection //
if (!cliArguments.disableCsrf) {
const COOKIES_SECRET = userModule.getCookieSecret();
const { generateToken, doubleCsrfProtection } = doubleCsrf({
getSecret: userModule.getCsrfSecret,
cookieName: 'X-CSRF-Token',
cookieOptions: {
httpOnly: true,
sameSite: 'strict',
secure: false,
},
size: 64,
getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
app.get('/csrf-token', (req, res) => {
res.json({
'token': generateToken(res, req),
});
});
app.use(cookieParser(COOKIES_SECRET));
app.use(doubleCsrfProtection);
} else {
console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
app.get('/csrf-token', (req, res) => {
res.json({
'token': 'disabled',
});
});
}
// Static files
// Host index page
app.get('/', (request, response) => {
if (userModule.shouldRedirectToLogin(request)) {
return response.redirect('/login');
}
return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') });
});
// Host login page
app.get('/login', async (request, response) => {
if (!enableAccounts) {
console.log('User accounts are disabled. Redirecting to index page.');
return response.redirect('/');
}
const autoLogin = await userModule.tryAutoLogin(request);
if (autoLogin) {
return response.redirect('/');
}
return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
});
// Host frontend assets
app.use(express.static(process.cwd() + '/public', {}));
app.use('/backgrounds', (req, res) => {
const filePath = decodeURIComponent(path.join(process.cwd(), DIRECTORIES.backgrounds, req.url.replace(/%20/g, ' ')));
fs.readFile(filePath, (err, data) => {
if (err) {
res.status(404).send('File not found');
return;
}
//res.contentType('image/jpeg');
res.send(data);
});
});
// Public API
app.use('/api/users', require('./src/endpoints/users-public').router);
app.use('/characters', (req, res) => {
const filePath = decodeURIComponent(path.join(process.cwd(), DIRECTORIES.characters, req.url.replace(/%20/g, ' ')));
fs.readFile(filePath, (err, data) => {
if (err) {
res.status(404).send('File not found');
return;
}
res.send(data);
});
});
// Everything below this line requires authentication
app.use(userModule.requireLoginMiddleware);
// File uploads
app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
app.get('/', function (request, response) {
response.sendFile(process.cwd() + '/public/index.html');
});
// User data mount
app.use('/', userModule.router);
// Private endpoints
app.use('/api/users', require('./src/endpoints/users-private').router);
// Admin endpoints
app.use('/api/users', require('./src/endpoints/users-admin').router);
app.get('/version', async function (_, response) {
const data = await getVersion();
response.send(data);
@ -488,10 +531,17 @@ const setupTasks = async function () {
// TODO: do endpoint init functions depend on certain directories existing or not existing? They should be callable
// in any order for encapsulation reasons, but right now it's unknown if that would break anything.
await userModule.initUserStorage(dataRoot);
if (listen && !basicAuthMode && enableAccounts) {
await userModule.checkAccountsProtection();
}
await settingsEndpoint.init();
ensurePublicDirectoriesExist();
const directories = await userModule.ensurePublicDirectoriesExist();
await userModule.migrateUserData();
await contentManager.checkForNewContent(directories);
await ensureThumbnailCache();
contentManager.checkForNewContent();
cleanUploads();
await loadTokenizers();
@ -551,7 +601,7 @@ async function loadPlugins() {
}
}
if (listen && !getConfigValue('whitelistMode', true) && !basicAuthMode) {
if (listen && !enableWhitelist && !basicAuthMode) {
if (getConfigValue('securityOverride', false)) {
console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
}
@ -579,11 +629,3 @@ if (cliArguments.ssl) {
setupTasks,
);
}
function ensurePublicDirectoriesExist() {
for (const dir of Object.values(DIRECTORIES)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
}

View File

@ -2,8 +2,13 @@ const { TEXTGEN_TYPES, OPENROUTER_HEADERS } = require('./constants');
const { SECRET_KEYS, readSecret } = require('./endpoints/secrets');
const { getConfigValue } = require('./util');
function getMancerHeaders() {
const apiKey = readSecret(SECRET_KEYS.MANCER);
/**
* Gets the headers for the Mancer API.
* @param {import('./users').UserDirectoryList} directories User directories
* @returns {object} Headers for the request
*/
function getMancerHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.MANCER);
return apiKey ? ({
'X-API-KEY': apiKey,
@ -11,39 +16,64 @@ function getMancerHeaders() {
}) : {};
}
function getTogetherAIHeaders() {
const apiKey = readSecret(SECRET_KEYS.TOGETHERAI);
/**
* Gets the headers for the TogetherAI API.
* @param {import('./users').UserDirectoryList} directories User directories
* @returns {object} Headers for the request
*/
function getTogetherAIHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.TOGETHERAI);
return apiKey ? ({
'Authorization': `Bearer ${apiKey}`,
}) : {};
}
function getInfermaticAIHeaders() {
const apiKey = readSecret(SECRET_KEYS.INFERMATICAI);
/**
* Gets the headers for the InfermaticAI API.
* @param {import('./users').UserDirectoryList} directories User directories
* @returns {object} Headers for the request
*/
function getInfermaticAIHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.INFERMATICAI);
return apiKey ? ({
'Authorization': `Bearer ${apiKey}`,
}) : {};
}
function getDreamGenHeaders() {
const apiKey = readSecret(SECRET_KEYS.DREAMGEN);
/**
* Gets the headers for the DreamGen API.
* @param {import('./users').UserDirectoryList} directories User directories
* @returns {object} Headers for the request
*/
function getDreamGenHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.DREAMGEN);
return apiKey ? ({
'Authorization': `Bearer ${apiKey}`,
}) : {};
}
function getOpenRouterHeaders() {
const apiKey = readSecret(SECRET_KEYS.OPENROUTER);
/**
* Gets the headers for the OpenRouter API.
* @param {import('./users').UserDirectoryList} directories User directories
* @returns {object} Headers for the request
*/
function getOpenRouterHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.OPENROUTER);
const baseHeaders = { ...OPENROUTER_HEADERS };
return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders;
}
function getAphroditeHeaders() {
const apiKey = readSecret(SECRET_KEYS.APHRODITE);
/**
* Gets the headers for the Aphrodite API.
* @param {import('./users').UserDirectoryList} directories User directories
* @returns {object} Headers for the request
*/
function getAphroditeHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.APHRODITE);
return apiKey ? ({
'X-API-KEY': apiKey,
@ -51,8 +81,13 @@ function getAphroditeHeaders() {
}) : {};
}
function getTabbyHeaders() {
const apiKey = readSecret(SECRET_KEYS.TABBY);
/**
* Gets the headers for the Tabby API.
* @param {import('./users').UserDirectoryList} directories User directories
* @returns {object} Headers for the request
*/
function getTabbyHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.TABBY);
return apiKey ? ({
'x-api-key': apiKey,
@ -60,24 +95,39 @@ function getTabbyHeaders() {
}) : {};
}
function getLlamaCppHeaders() {
const apiKey = readSecret(SECRET_KEYS.LLAMACPP);
/**
* Gets the headers for the LlamaCPP API.
* @param {import('./users').UserDirectoryList} directories User directories
* @returns {object} Headers for the request
*/
function getLlamaCppHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.LLAMACPP);
return apiKey ? ({
'Authorization': `Bearer ${apiKey}`,
}) : {};
}
function getOobaHeaders() {
const apiKey = readSecret(SECRET_KEYS.OOBA);
/**
* Gets the headers for the Ooba API.
* @param {import('./users').UserDirectoryList} directories
* @returns {object} Headers for the request
*/
function getOobaHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.OOBA);
return apiKey ? ({
'Authorization': `Bearer ${apiKey}`,
}) : {};
}
function getKoboldCppHeaders() {
const apiKey = readSecret(SECRET_KEYS.KOBOLDCPP);
/**
* Gets the headers for the KoboldCpp API.
* @param {import('./users').UserDirectoryList} directories
* @returns {object} Headers for the request
*/
function getKoboldCppHeaders(directories) {
const apiKey = readSecret(directories, SECRET_KEYS.KOBOLDCPP);
return apiKey ? ({
'Authorization': `Bearer ${apiKey}`,
@ -96,7 +146,7 @@ function getOverrideHeaders(urlHost) {
/**
* Sets additional headers for the request.
* @param {object} request Original request body
* @param {import('express').Request} request Original request body
* @param {object} args New request arguments
* @param {string|null} server API server for new request
*/
@ -115,7 +165,7 @@ function setAdditionalHeaders(request, args, server) {
};
const getHeaders = headerGetters[request.body.api_type];
const headers = getHeaders ? getHeaders() : {};
const headers = getHeaders ? getHeaders(request.user.directories) : {};
if (typeof server === 'string' && server.length > 0) {
try {

View File

@ -1,34 +1,62 @@
const DIRECTORIES = {
worlds: 'public/worlds/',
user: 'public/user',
avatars: 'public/User Avatars',
const PUBLIC_DIRECTORIES = {
images: 'public/img/',
userImages: 'public/user/images/',
groups: 'public/groups/',
groupChats: 'public/group chats',
chats: 'public/chats/',
characters: 'public/characters/',
backgrounds: 'public/backgrounds',
novelAI_Settings: 'public/NovelAI Settings',
koboldAI_Settings: 'public/KoboldAI Settings',
openAI_Settings: 'public/OpenAI Settings',
textGen_Settings: 'public/TextGen Settings',
thumbnails: 'thumbnails/',
thumbnailsBg: 'thumbnails/bg/',
thumbnailsAvatar: 'thumbnails/avatar/',
themes: 'public/themes',
movingUI: 'public/movingUI',
extensions: 'public/scripts/extensions',
instruct: 'public/instruct',
context: 'public/context',
backups: 'backups/',
quickreplies: 'public/QuickReplies',
assets: 'public/assets',
comfyWorkflows: 'public/user/workflows',
files: 'public/user/files',
sounds: 'public/sounds',
extensions: 'public/scripts/extensions',
};
const DEFAULT_AVATAR = '/img/ai4.png';
const SETTINGS_FILE = 'settings.json';
/**
* @type {import('./users').UserDirectoryList}
* @readonly
* @enum {string}
*/
const USER_DIRECTORY_TEMPLATE = Object.freeze({
root: '',
thumbnails: 'thumbnails',
thumbnailsBg: 'thumbnails/bg',
thumbnailsAvatar: 'thumbnails/avatar',
worlds: 'worlds',
user: 'user',
avatars: 'User Avatars',
userImages: 'user/images',
groups: 'groups',
groupChats: 'group chats',
chats: 'chats',
characters: 'characters',
backgrounds: 'backgrounds',
novelAI_Settings: 'NovelAI Settings',
koboldAI_Settings: 'KoboldAI Settings',
openAI_Settings: 'OpenAI Settings',
textGen_Settings: 'TextGen Settings',
themes: 'themes',
movingUI: 'movingUI',
extensions: 'extensions',
instruct: 'instruct',
context: 'context',
quickreplies: 'QuickReplies',
assets: 'assets',
comfyWorkflows: 'user/workflows',
files: 'user/files',
vectors: 'vectors',
});
/**
* @type {import('./users').User}
* @readonly
*/
const DEFAULT_USER = Object.freeze({
handle: 'default-user',
name: 'User',
created: Date.now(),
password: '',
admin: true,
enabled: true,
salt: '',
});
const UNSAFE_EXTENSIONS = [
'.php',
'.exe',
@ -270,7 +298,11 @@ const OPENROUTER_KEYS = [
];
module.exports = {
DIRECTORIES,
DEFAULT_USER,
DEFAULT_AVATAR,
SETTINGS_FILE,
PUBLIC_DIRECTORIES,
USER_DIRECTORY_TEMPLATE,
UNSAFE_EXTENSIONS,
UPLOADS_PATH,
GEMINI_SAFETY,

View File

@ -39,7 +39,7 @@ router.post('/caption-image', jsonParser, async (request, response) => {
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'x-api-key': request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.CLAUDE),
'x-api-key': request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE),
},
timeout: 0,
});

View File

@ -1,14 +1,15 @@
const path = require('path');
const fs = require('fs');
const mime = require('mime-types');
const express = require('express');
const sanitize = require('sanitize-filename');
const fetch = require('node-fetch').default;
const { finished } = require('stream/promises');
const { DIRECTORIES, UNSAFE_EXTENSIONS } = require('../constants');
const { UNSAFE_EXTENSIONS } = require('../constants');
const { jsonParser } = require('../express-common');
const { clientRelativePath } = require('../util');
const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character'];
const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character', 'temp'];
/**
* Validates the input filename for the asset.
@ -48,7 +49,12 @@ function validateAssetFileName(inputFilename) {
return { error: false };
}
// Recursive function to get files
/**
* Recursive function to get files
* @param {string} dir - The directory to search for files
* @param {string[]} files - The array of files to return
* @returns {string[]} - The array of files
*/
function getFiles(dir, files = []) {
// Get an array of all files and directories in the passed directory using fs.readdirSync
const fileList = fs.readdirSync(dir, { withFileTypes: true });
@ -77,13 +83,23 @@ const router = express.Router();
*
* @returns {void}
*/
router.post('/get', jsonParser, async (_, response) => {
const folderPath = path.join(DIRECTORIES.assets);
router.post('/get', jsonParser, async (request, response) => {
const folderPath = path.join(request.user.directories.assets);
let output = {};
//console.info("Checking files into",folderPath);
try {
if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
for (const category of VALID_CATEGORIES) {
const assetCategoryPath = path.join(folderPath, category);
if (fs.existsSync(assetCategoryPath) && !fs.statSync(assetCategoryPath).isDirectory()) {
fs.unlinkSync(assetCategoryPath);
}
if (!fs.existsSync(assetCategoryPath)) {
fs.mkdirSync(assetCategoryPath);
}
}
const folders = fs.readdirSync(folderPath, { withFileTypes: true })
.filter(file => file.isDirectory());
@ -100,7 +116,7 @@ router.post('/get', jsonParser, async (_, response) => {
for (let file of files) {
if (file.includes('model') && file.endsWith('.json')) {
//console.debug("Asset live2d model found:",file)
output[folder].push(clientRelativePath(file));
output[folder].push(clientRelativePath(request.user.directories.root, file));
}
}
continue;
@ -116,7 +132,7 @@ router.post('/get', jsonParser, async (_, response) => {
for (let file of files) {
if (!file.endsWith('.placeholder')) {
//console.debug("Asset VRM model found:",file)
output['vrm']['model'].push(clientRelativePath(file));
output['vrm']['model'].push(clientRelativePath(request.user.directories.root, file));
}
}
@ -127,7 +143,7 @@ router.post('/get', jsonParser, async (_, response) => {
for (let file of files) {
if (!file.endsWith('.placeholder')) {
//console.debug("Asset VRM animation found:",file)
output['vrm']['animation'].push(clientRelativePath(file));
output['vrm']['animation'].push(clientRelativePath(request.user.directories.root, file));
}
}
continue;
@ -170,7 +186,7 @@ router.post('/download', jsonParser, async (request, response) => {
category = i;
if (category === null) {
console.debug('Bad request: unsuported asset category.');
console.debug('Bad request: unsupported asset category.');
return response.sendStatus(400);
}
@ -179,8 +195,8 @@ router.post('/download', jsonParser, async (request, response) => {
if (validation.error)
return response.status(400).send(validation.message);
const temp_path = path.join(DIRECTORIES.assets, 'temp', request.body.filename);
const file_path = path.join(DIRECTORIES.assets, category, request.body.filename);
const temp_path = path.join(request.user.directories.assets, 'temp', request.body.filename);
const file_path = path.join(request.user.directories.assets, category, request.body.filename);
console.debug('Request received to download', url, 'to', file_path);
try {
@ -197,12 +213,15 @@ router.post('/download', jsonParser, async (request, response) => {
});
}
const fileStream = fs.createWriteStream(destination, { flags: 'wx' });
// @ts-ignore
await finished(res.body.pipe(fileStream));
if (category === 'character') {
response.sendFile(temp_path, { root: process.cwd() }, () => {
const fileContent = fs.readFileSync(temp_path);
const contentType = mime.lookup(temp_path) || 'application/octet-stream';
response.setHeader('Content-Type', contentType);
response.send(fileContent);
fs.rmSync(temp_path);
});
return;
}
@ -235,7 +254,7 @@ router.post('/delete', jsonParser, async (request, response) => {
category = i;
if (category === null) {
console.debug('Bad request: unsuported asset category.');
console.debug('Bad request: unsupported asset category.');
return response.sendStatus(400);
}
@ -244,7 +263,7 @@ router.post('/delete', jsonParser, async (request, response) => {
if (validation.error)
return response.status(400).send(validation.message);
const file_path = path.join(DIRECTORIES.assets, category, request.body.filename);
const file_path = path.join(request.user.directories.assets, category, request.body.filename);
console.debug('Request received to delete', category, file_path);
try {
@ -290,11 +309,11 @@ router.post('/character', jsonParser, async (request, response) => {
category = i;
if (category === null) {
console.debug('Bad request: unsuported asset category.');
console.debug('Bad request: unsupported asset category.');
return response.sendStatus(400);
}
const folderPath = path.join(DIRECTORIES.characters, name, category);
const folderPath = path.join(request.user.directories.characters, name, category);
let output = [];
try {

View File

@ -4,7 +4,7 @@ const fs = require('fs');
const sanitize = require('sanitize-filename');
const writeFileAtomicSync = require('write-file-atomic').sync;
const { jsonParser, urlencodedParser } = require('../express-common');
const { DIRECTORIES, AVATAR_WIDTH, AVATAR_HEIGHT, UPLOADS_PATH } = require('../constants');
const { AVATAR_WIDTH, AVATAR_HEIGHT, UPLOADS_PATH } = require('../constants');
const { getImages, tryParse } = require('../util');
// image processing related library imports
@ -13,7 +13,7 @@ const jimp = require('jimp');
const router = express.Router();
router.post('/get', jsonParser, function (request, response) {
var images = getImages(DIRECTORIES.avatars);
var images = getImages(request.user.directories.avatars);
response.send(JSON.stringify(images));
});
@ -25,7 +25,7 @@ router.post('/delete', jsonParser, function (request, response) {
return response.sendStatus(403);
}
const fileName = path.join(DIRECTORIES.avatars, sanitize(request.body.avatar));
const fileName = path.join(request.user.directories.avatars, sanitize(request.body.avatar));
if (fs.existsSync(fileName)) {
fs.rmSync(fileName);
@ -50,7 +50,7 @@ router.post('/upload', urlencodedParser, async (request, response) => {
const image = await rawImg.cover(AVATAR_WIDTH, AVATAR_HEIGHT).getBufferAsync(jimp.MIME_PNG);
const filename = request.body.overwrite_name || `${Date.now()}.png`;
const pathToNewFile = path.join(DIRECTORIES.avatars, filename);
const pathToNewFile = path.join(request.user.directories.avatars, filename);
writeFileAtomicSync(pathToNewFile, image);
fs.rmSync(pathToUpload);
return response.send({ path: filename });

View File

@ -98,7 +98,7 @@ async function parseCohereStream(jsonStream, request, response) {
*/
async function sendClaudeRequest(request, response) {
const apiUrl = new URL(request.body.reverse_proxy || API_CLAUDE).toString();
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.CLAUDE);
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE);
const divider = '-'.repeat(process.stdout.columns);
if (!apiKey) {
@ -179,7 +179,7 @@ async function sendClaudeRequest(request, response) {
*/
async function sendScaleRequest(request, response) {
const apiUrl = new URL(request.body.api_url_scale).toString();
const apiKey = readSecret(SECRET_KEYS.SCALE);
const apiKey = readSecret(request.user.directories, SECRET_KEYS.SCALE);
if (!apiKey) {
console.log('Scale API key is missing.');
@ -230,7 +230,7 @@ async function sendScaleRequest(request, response) {
* @param {express.Response} response Express response
*/
async function sendMakerSuiteRequest(request, response) {
const apiKey = readSecret(SECRET_KEYS.MAKERSUITE);
const apiKey = readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
if (!apiKey) {
console.log('MakerSuite API key is missing.');
@ -392,7 +392,7 @@ async function sendAI21Request(request, response) {
headers: {
accept: 'application/json',
'content-type': 'application/json',
Authorization: `Bearer ${readSecret(SECRET_KEYS.AI21)}`,
Authorization: `Bearer ${readSecret(request.user.directories, SECRET_KEYS.AI21)}`,
},
body: JSON.stringify({
numResults: 1,
@ -456,7 +456,7 @@ async function sendAI21Request(request, response) {
*/
async function sendMistralAIRequest(request, response) {
const apiUrl = new URL(request.body.reverse_proxy || API_MISTRAL).toString();
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.MISTRALAI);
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
if (!apiKey) {
console.log('MistralAI API key is missing.');
@ -553,7 +553,7 @@ async function sendMistralAIRequest(request, response) {
* @param {express.Response} response Express response
*/
async function sendCohereRequest(request, response) {
const apiKey = readSecret(SECRET_KEYS.COHERE);
const apiKey = readSecret(request.user.directories, SECRET_KEYS.COHERE);
const controller = new AbortController();
request.socket.removeAllListeners('close');
request.socket.on('close', function () {
@ -642,25 +642,25 @@ router.post('/status', jsonParser, async function (request, response_getstatus_o
if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) {
api_url = new URL(request.body.reverse_proxy || API_OPENAI).toString();
api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.OPENAI);
api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.OPENAI);
headers = {};
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) {
api_url = 'https://openrouter.ai/api/v1';
api_key_openai = readSecret(SECRET_KEYS.OPENROUTER);
api_key_openai = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
// OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests
headers = { ...OPENROUTER_HEADERS };
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.MISTRALAI) {
api_url = new URL(request.body.reverse_proxy || API_MISTRAL).toString();
api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.MISTRALAI);
api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
headers = {};
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
api_url = request.body.custom_url;
api_key_openai = readSecret(SECRET_KEYS.CUSTOM);
api_key_openai = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
headers = {};
mergeObjectWithYaml(headers, request.body.custom_include_headers);
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.COHERE) {
api_url = API_COHERE;
api_key_openai = readSecret(SECRET_KEYS.COHERE);
api_key_openai = readSecret(request.user.directories, SECRET_KEYS.COHERE);
headers = {};
} else {
console.log('This chat completion source is not supported yet.');
@ -825,10 +825,11 @@ router.post('/generate', jsonParser, function (request, response) {
if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) {
apiUrl = new URL(request.body.reverse_proxy || API_OPENAI).toString();
apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.OPENAI);
apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.OPENAI);
headers = {};
bodyParams = {
logprobs: request.body.logprobs,
top_logprobs: undefined,
};
// Adjust logprobs params for Chat Completions API, which expects { top_logprobs: number; logprobs: boolean; }
@ -842,7 +843,7 @@ router.post('/generate', jsonParser, function (request, response) {
}
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) {
apiUrl = 'https://openrouter.ai/api/v1';
apiKey = readSecret(SECRET_KEYS.OPENROUTER);
apiKey = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
// OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests
headers = { ...OPENROUTER_HEADERS };
bodyParams = { 'transforms': ['middle-out'] };
@ -864,10 +865,11 @@ router.post('/generate', jsonParser, function (request, response) {
}
} else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
apiUrl = request.body.custom_url;
apiKey = readSecret(SECRET_KEYS.CUSTOM);
apiKey = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
headers = {};
bodyParams = {
logprobs: request.body.logprobs,
top_logprobs: undefined,
};
// Adjust logprobs params for Chat Completions API, which expects { top_logprobs: number; logprobs: boolean; }

View File

@ -14,7 +14,7 @@ router.post('/generate', jsonParser, function (request, response) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'cookie': `_jwt=${readSecret(SECRET_KEYS.SCALE_COOKIE)}`,
'cookie': `_jwt=${readSecret(request.user.directories, SECRET_KEYS.SCALE_COOKIE)}`,
},
body: JSON.stringify({
json: {

View File

@ -516,7 +516,7 @@ llamacpp.post('/slots', jsonParser, async function (request, response) {
const baseUrl = trimV1(request.body.server_url);
let fetchResponse;
if (request.body.action === "info") {
if (request.body.action === 'info') {
fetchResponse = await fetch(`${baseUrl}/slots`, {
method: 'GET',
timeout: 0,
@ -525,7 +525,7 @@ llamacpp.post('/slots', jsonParser, async function (request, response) {
if (!/^\d+$/.test(request.body.id_slot)) {
return response.sendStatus(400);
}
if (request.body.action !== "erase" && !request.body.filename) {
if (request.body.action !== 'erase' && !request.body.filename) {
return response.sendStatus(400);
}
@ -534,7 +534,7 @@ llamacpp.post('/slots', jsonParser, async function (request, response) {
headers: { 'Content-Type': 'application/json' },
timeout: 0,
body: JSON.stringify({
filename: request.body.action !== "erase" ? `${request.body.filename}` : undefined,
filename: request.body.action !== 'erase' ? `${request.body.filename}` : undefined,
}),
});
}

View File

@ -4,16 +4,15 @@ const express = require('express');
const sanitize = require('sanitize-filename');
const { jsonParser, urlencodedParser } = require('../express-common');
const { DIRECTORIES, UPLOADS_PATH } = require('../constants');
const { UPLOADS_PATH } = require('../constants');
const { invalidateThumbnail } = require('./thumbnails');
const { getImages } = require('../util');
const router = express.Router();
router.post('/all', jsonParser, function (request, response) {
var images = getImages('public/backgrounds');
var images = getImages(request.user.directories.backgrounds);
response.send(JSON.stringify(images));
});
router.post('/delete', jsonParser, function (request, response) {
@ -24,7 +23,7 @@ router.post('/delete', jsonParser, function (request, response) {
return response.sendStatus(403);
}
const fileName = path.join('public/backgrounds/', sanitize(request.body.bg));
const fileName = path.join(request.user.directories.backgrounds, sanitize(request.body.bg));
if (!fs.existsSync(fileName)) {
console.log('BG file not found');
@ -32,15 +31,15 @@ router.post('/delete', jsonParser, function (request, response) {
}
fs.rmSync(fileName);
invalidateThumbnail('bg', request.body.bg);
invalidateThumbnail(request.user.directories, 'bg', request.body.bg);
return response.send('ok');
});
router.post('/rename', jsonParser, function (request, response) {
if (!request.body) return response.sendStatus(400);
const oldFileName = path.join(DIRECTORIES.backgrounds, sanitize(request.body.old_bg));
const newFileName = path.join(DIRECTORIES.backgrounds, sanitize(request.body.new_bg));
const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));
const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg));
if (!fs.existsSync(oldFileName)) {
console.log('BG file not found');
@ -53,7 +52,7 @@ router.post('/rename', jsonParser, function (request, response) {
}
fs.renameSync(oldFileName, newFileName);
invalidateThumbnail('bg', request.body.old_bg);
invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);
return response.send('ok');
});
@ -64,8 +63,8 @@ router.post('/upload', urlencodedParser, function (request, response) {
const filename = request.file.originalname;
try {
fs.renameSync(img_path, path.join('public/backgrounds/', filename));
invalidateThumbnail('bg', filename);
fs.renameSync(img_path, path.join(request.user.directories.backgrounds, filename));
invalidateThumbnail(request.user.directories, 'bg', filename);
response.send(filename);
} catch (err) {
console.error(err);

Some files were not shown because too many files have changed in this diff Show More