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* readme*
Start.bat Start.bat
/dist /dist
/backups/ /backups
cloudflared.exe cloudflared.exe
access.log access.log
/data

1
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
FROM node:19.1.0-alpine3.16 FROM node:lts-alpine3.18
# Arguments # Arguments
ARG APP_HOME=/home/node/app ARG APP_HOME=/home/node/app
@ -26,19 +26,9 @@ COPY . ./
# Copy default chats, characters and user avatars to <folder>.default folder # Copy default chats, characters and user avatars to <folder>.default folder
RUN \ 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" && \ rm -f "config.yaml" || true && \
\
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 && \
ln -s "./config/config.yaml" "config.yaml" || true && \ ln -s "./config/config.yaml" "config.yaml" || true && \
ln -s "../config/settings.json" "public/settings.json" || true && \ mkdir "config" || true
mkdir "config" || true && \
mkdir -p "public/user" || true
# Cleanup unnecessary files # Cleanup unnecessary files
RUN \ 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 for incoming connections
listen: false listen: false
# Server port # Server port
port: 8000 port: 8000
# -- SECURITY CONFIGURATION --
# Toggle whitelist mode # Toggle whitelist mode
whitelistMode: true whitelistMode: true
# Whitelist of allowed IP addresses # Whitelist of allowed IP addresses
@ -16,6 +20,12 @@ basicAuthUser:
password: "password" password: "password"
# Enables CORS proxy middleware # Enables CORS proxy middleware
enableCorsProxy: false 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 # Disable security checks - NOT RECOMMENDED
securityOverride: false securityOverride: false
# -- ADVANCED CONFIGURATION -- # -- 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", "filename": "default_Seraphina.png",
"type": "character" "type": "character"
@ -211,7 +315,6 @@
"filename": "presets/novel/Writers-Daemon-Kayra.json", "filename": "presets/novel/Writers-Daemon-Kayra.json",
"type": "novel_preset" "type": "novel_preset"
}, },
{ {
"filename": "presets/textgen/Asterism.json", "filename": "presets/textgen/Asterism.json",
"type": "textgen_preset" "type": "textgen_preset"
@ -511,5 +614,17 @@
{ {
"filename": "presets/instruct/simple-proxy-for-tavern.json", "filename": "presets/instruct/simple-proxy-for-tavern.json",
"type": "instruct" "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": "", "user_prompt_bias": "",
"show_user_prompt_bias": true, "show_user_prompt_bias": true,
"markdown_escape_strings": "", "markdown_escape_strings": "",
"fast_ui_mode": false, "fast_ui_mode": true,
"avatar_style": 0, "avatar_style": 0,
"chat_display": 0, "chat_display": 0,
"chat_width": 50, "chat_width": 50,
@ -115,16 +115,17 @@
"italics_text_color": "rgba(145, 145, 145, 1)", "italics_text_color": "rgba(145, 145, 145, 1)",
"underline_text_color": "rgba(188, 231, 207, 1)", "underline_text_color": "rgba(188, 231, 207, 1)",
"quote_text_color": "rgba(225, 138, 36, 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)", "blur_tint_color": "rgba(23, 23, 23, 1)",
"user_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(0, 0, 0, 0.9)", "bot_mes_blur_tint_color": "rgba(30, 30, 30, 0.9)",
"shadow_color": "rgba(0, 0, 0, 1)", "shadow_color": "rgba(0, 0, 0, 1)",
"waifuMode": false, "waifuMode": false,
"movingUI": false, "movingUI": false,
"movingUIState": {}, "movingUIState": {},
"movingUIPreset": "Default", "movingUIPreset": "Default",
"noShadows": true, "noShadows": true,
"theme": "Default (Dark) 1.7.1", "theme": "Dark Lite",
"auto_swipe": false, "auto_swipe": false,
"auto_swipe_minimum_length": 0, "auto_swipe_minimum_length": 0,
"auto_swipe_blacklist": [], "auto_swipe_blacklist": [],
@ -139,7 +140,7 @@
"hotswap_enabled": true, "hotswap_enabled": true,
"timer_enabled": false, "timer_enabled": false,
"timestamps_enabled": true, "timestamps_enabled": true,
"timestamp_model_icon": false, "timestamp_model_icon": true,
"mesIDDisplay_enabled": false, "mesIDDisplay_enabled": false,
"max_context_unlocked": false, "max_context_unlocked": false,
"prefer_character_prompt": true, "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: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- "./extensions:/home/node/app/public/scripts/extensions/third-party"
- "./config:/home/node/app/config" - "./config:/home/node/app/config"
- "./user:/home/node/app/public/user" - "./data:/home/node/app/data"
restart: unless-stopped restart: unless-stopped

View File

@ -1,38 +1,14 @@
#!/bin/sh #!/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 if [ ! -e "config/config.yaml" ]; then
echo "Resource not found, copying from defaults: config.yaml" echo "Resource not found, copying from defaults: config.yaml"
cp -r "default/config.yaml" "config/config.yaml" cp -r "default/config.yaml" "config/config.yaml"
fi 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" CONFIG_FILE="config.yaml"
echo "Starting with the following config:" echo "Starting with the following config:"
cat $CONFIG_FILE 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 # 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": [ "exclude": [
"node_modules", "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", "@agnai/web-tokenizers": "^0.1.3",
"@dqbd/tiktoken": "^1.0.13", "@dqbd/tiktoken": "^1.0.13",
"@zeldafan0225/ai_horde": "^4.0.1", "@zeldafan0225/ai_horde": "^4.0.1",
"archiver": "^7.0.1",
"bing-translate-api": "^2.9.1", "bing-translate-api": "^2.9.1",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"command-exists": "^1.2.9", "command-exists": "^1.2.9",
"compression": "^1", "compression": "^1",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cookie-session": "^2.1.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"csrf-csrf": "^2.2.3", "csrf-csrf": "^2.2.3",
"express": "^4.19.2", "express": "^4.19.2",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"google-translate-api-browser": "^3.0.1", "google-translate-api-browser": "^3.0.1",
"gpt3-tokenizer": "^1.1.5", "gpt3-tokenizer": "^1.1.5",
"helmet": "^7.1.0",
"ip-matching": "^2.1.2", "ip-matching": "^2.1.2",
"ipaddr.js": "^2.0.1", "ipaddr.js": "^2.0.1",
"jimp": "^0.22.10", "jimp": "^0.22.10",
@ -22,10 +25,12 @@
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-fetch": "^2.6.11", "node-fetch": "^2.6.11",
"node-persist": "^4.0.1",
"open": "^8.4.2", "open": "^8.4.2",
"png-chunk-text": "^1.0.0", "png-chunk-text": "^1.0.0",
"png-chunks-encode": "^1.0.0", "png-chunks-encode": "^1.0.0",
"png-chunks-extract": "^1.0.0", "png-chunks-extract": "^1.0.0",
"rate-limiter-flexible": "^5.0.0",
"response-time": "^2.3.2", "response-time": "^2.3.2",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sillytavern-transformers": "^2.14.6", "sillytavern-transformers": "^2.14.6",
@ -45,6 +50,9 @@
"vectra": { "vectra": {
"openai": "^4.17.0" "openai": "^4.17.0"
}, },
"load-bmfont": {
"phin": "^3.7.1"
},
"axios": { "axios": {
"follow-redirects": "^1.15.4" "follow-redirects": "^1.15.4"
}, },
@ -59,7 +67,7 @@
"type": "git", "type": "git",
"url": "https://github.com/SillyTavern/SillyTavern.git" "url": "https://github.com/SillyTavern/SillyTavern.git"
}, },
"version": "1.11.7", "version": "1.12.0-preview",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"start-multi": "node server.js --disableCsrf", "start-multi": "node server.js --disableCsrf",
@ -76,6 +84,7 @@
}, },
"main": "server.js", "main": "server.js",
"devDependencies": { "devDependencies": {
"@types/jquery": "^3.5.29",
"eslint": "^8.55.0", "eslint": "^8.55.0",
"jquery": "^3.6.4" "jquery": "^3.6.4"
} }

View File

@ -106,7 +106,6 @@ function addMissingConfigValues() {
*/ */
function createDefaultFiles() { function createDefaultFiles() {
const files = { const files = {
settings: './public/settings.json',
config: './config.yaml', config: './config.yaml',
user: './public/css/user.css', 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 { try {
// 0. Convert config.conf to config.yaml // 0. Convert config.conf to config.yaml
convertConfig(); convertConfig();
@ -199,8 +175,6 @@ try {
copyWasmFiles(); copyWasmFiles();
// 3. Add missing config values // 3. Add missing config values
addMissingConfigValues(); addMissingConfigValues();
// 4. Migrate bg_load.css to settings.json
migrateBackground();
} catch (error) { } catch (error) {
console.error(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/bulk-edit.js"></script>
<script type="module" src="scripts/cfg-scale.js"></script> <script type="module" src="scripts/cfg-scale.js"></script>
<script type="module" src="scripts/chats.js"></script> <script type="module" src="scripts/chats.js"></script>
<script type="module" src="scripts/user.js"></script>
<title>SillyTavern</title> <title>SillyTavern</title>
</head> </head>
@ -3474,7 +3475,21 @@
<small id="version_display"></small> <small id="version_display"></small>
</div> </div>
<div name="UserSettingsRowTwo" class="flex-container flexFlowRow"> <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> </div>
<div id="user-settings-block-content" class="flex-container spaceEvenly"> <div id="user-settings-block-content" class="flex-container spaceEvenly">
@ -5349,17 +5364,16 @@
Enable simple UI mode Enable simple UI mode
</span> </span>
</label> </label>
<h3 data-i18n="Your Persona">
Your Persona
</h3>
<div class="justifyLeft margin-bot-10px"> <div class="justifyLeft margin-bot-10px">
<span data-i18n="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 user name. Before you get started, you must select a persona name.
</span> </span>
This can be changed at any time via the <code><i class="fa-solid fa-face-smile"></i></code> icon. This can be changed at any time via the <code><i class="fa-solid fa-face-smile"></i></code> icon.
</div> </div>
<h4 data-i18n="UI Language:">UI Language:</h4> <h4 data-i18n="Persona Name:">Persona Name:</h4>
<select name="onboarding_ui_language">
<option value="en">English</option>
</select>
<h4 data-i18n="User Name:">User Name:</h4>
</div> </div>
</div> </div>
<div id="group_member_template" class="template_element"> <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 { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId } from './scripts/chats.js';
import { initPresetManager } from './scripts/preset-manager.js'; import { initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros } from './scripts/macros.js'; import { evaluateMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js';
import { callGenericPopup } from './scripts/popup.js'; import { callGenericPopup } from './scripts/popup.js';
import { renderTemplate, renderTemplateAsync } from './scripts/templates.js'; import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
@ -665,13 +666,15 @@ async function getSystemMessages() {
registerPromptManagerMigration(); registerPromptManagerMigration();
$(document).ajaxError(function myErrorHandler(_, xhr) { $(document).ajaxError(function myErrorHandler(_, xhr) {
// Cohee: CSRF doesn't error out in multiple tabs anymore, so this is unnecessary
/*
if (xhr.status == 403) { if (xhr.status == 403) {
toastr.warning( 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.', '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', 'Looks like you\'ve opened SillyTavern in another browser tab',
{ timeOut: 0, extendedTimeOut: 0, preventDuplicates: true }, { timeOut: 0, extendedTimeOut: 0, preventDuplicates: true },
); );
} } */
}); });
async function getClientVersion() { async function getClientVersion() {
@ -1500,7 +1503,7 @@ function getCharacterSource(chId = this_chid) {
} }
async function getCharacters() { async function getCharacters() {
var response = await fetch('/api/characters/all', { const response = await fetch('/api/characters/all', {
method: 'POST', method: 'POST',
headers: getRequestHeaders(), headers: getRequestHeaders(),
body: JSON.stringify({ body: JSON.stringify({
@ -1508,11 +1511,9 @@ async function getCharacters() {
}), }),
}); });
if (response.ok === true) { if (response.ok === true) {
var getData = ''; //RossAscends: reset to force array to update to account for deleted character. characters.splice(0, characters.length);
getData = await response.json(); const getData = await response.json();
const load_ch_count = Object.getOwnPropertyNames(getData); for (let i = 0; i < getData.length; i++) {
for (var i = 0; i < load_ch_count.length; i++) {
characters[i] = [];
characters[i] = getData[i]; characters[i] = getData[i];
characters[i]['name'] = DOMPurify.sanitize(characters[i]['name']); 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 () { template.find('input[name="enable_simple_mode"]').on('input', function () {
simpleUiMode = $(this).is(':checked'); simpleUiMode = $(this).is(':checked');
}); });
var userName = await callPopup(template, 'input', name1); let userName = await callPopup(template, 'input', currentUser?.name || name1);
if (userName) { if (userName) {
userName = userName.replace('\n', ' '); userName = userName.replace('\n', ' ');
@ -6120,6 +6121,8 @@ async function getSettings() {
$('#your_name').val(name1); $('#your_name').val(name1);
} }
await setUserControls(data.enable_accounts);
// Allow subscribers to mutate settings // Allow subscribers to mutate settings
eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings); eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);
@ -10137,6 +10140,7 @@ jQuery(async function () {
'#character_cross', '#character_cross',
'#avatar-and-name-block', '#avatar-and-name-block',
'#shadow_popup', '#shadow_popup',
'.shadow_popup',
'#world_popup', '#world_popup',
'.ui-widget', '.ui-widget',
'.text_pole', '.text_pole',

View File

@ -266,7 +266,7 @@ class BulkTagPopupHandler {
printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(), tagOptions: { removable: true } }); printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
// Tag input with resolvable list for the mutual tags to get redrawn, so that newly added tags get sorted correctly // Tag input with resolvable list for the mutual tags to get redrawn, so that newly added tags get sorted correctly
createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true }}); createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this)); document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this));
document.querySelector('#bulk_tag_popup_remove_mutual').addEventListener('click', this.removeMutual.bind(this)); document.querySelector('#bulk_tag_popup_remove_mutual').addEventListener('click', this.removeMutual.bind(this));
@ -291,7 +291,7 @@ class BulkTagPopupHandler {
// Find mutual tags for multiple characters // Find mutual tags for multiple characters
const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid))); const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid)));
const mutualTags = allTags.reduce((mutual, characterTags) => 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); this.currentMutualTags = mutualTags.sort(compareTagsForSort);
@ -587,7 +587,7 @@ class BulkEditOverlay {
this.container.removeEventListener('mouseup', cancelHold); this.container.removeEventListener('mouseup', cancelHold);
this.container.removeEventListener('touchend', cancelHold); this.container.removeEventListener('touchend', cancelHold);
}, },
BulkEditOverlay.longPressDelay); BulkEditOverlay.longPressDelay);
}; };
handleLongPressEnd = (event) => { handleLongPressEnd = (event) => {
@ -694,7 +694,7 @@ class BulkEditOverlay {
} else { } else {
character.classList.remove(BulkEditOverlay.selectedClass); character.classList.remove(BulkEditOverlay.selectedClass);
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false; 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(); this.updateSelectedCount();
@ -816,7 +816,7 @@ class BulkEditOverlay {
<span>Also delete the chat files</span> <span>Also delete the chat files</span>
</label> </label>
</div>`; </div>`;
} };
/** /**
* Request user input before concurrently handle deletion * Request user input before concurrently handle deletion

View File

@ -263,7 +263,7 @@ async function RA_autoloadchat() {
await selectCharacterById(String(active_character_id)); await selectCharacterById(String(active_character_id));
// Do a little tomfoolery to spoof the tag selector // 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); applyTagsOnCharacterSelect.call(selectedCharElement);
} }
} }

View File

@ -44,22 +44,29 @@ function isConvertible(type) {
} }
/** /**
* Mark message as hidden (system message). * Mark a range of messages as hidden ("is_system") or not.
* @param {number} messageId Message ID * @param {number} start Starting message ID
* @param {JQuery<Element>} messageBlock Message UI element * @param {number} end Ending message ID (inclusive)
* @returns * @param {boolean} unhide If true, unhide the messages instead.
* @returns {Promise<void>}
*/ */
export async function hideChatMessage(messageId, messageBlock) { export async function hideChatMessageRange(start, end, unhide) {
const chatId = getCurrentChatId(); if (!getCurrentChatId()) return;
if (!chatId || isNaN(messageId)) return; if (isNaN(start)) return;
if (!end) end = start;
const hide = !unhide;
const message = chat[messageId]; 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; message.is_system = hide;
messageBlock.attr('is_system', String(true)); messageBlock.attr('is_system', String(hide));
}
// Reload swipes. Useful when a last message is hidden. // Reload swipes. Useful when a last message is hidden.
hideSwipeButtons(); 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 {number} messageId Message ID
* @param {JQuery<Element>} messageBlock Message UI element * @param {JQuery<Element>} _messageBlock Unused
* @returns * @returns {Promise<void>}
*/ */
export async function unhideChatMessage(messageId, messageBlock) { export async function hideChatMessage(messageId, _messageBlock) {
const chatId = getCurrentChatId(); return hideChatMessageRange(messageId, messageId, false);
}
if (!chatId || isNaN(messageId)) return; /**
* Mark message as visible (non-system message).
const message = chat[messageId]; * @deprecated Use hideChatMessageRange.
* @param {number} messageId Message ID
if (!message) return; * @param {JQuery<Element>} _messageBlock Unused
* @returns {Promise<void>}
message.is_system = false; */
messageBlock.attr('is_system', String(false)); export async function unhideChatMessage(messageId, _messageBlock) {
return hideChatMessageRange(messageId, messageId, true);
// Reload swipes. Useful when a last message is hidden.
hideSwipeButtons();
showSwipeButtons();
saveChatDebounced();
} }
/** /**
@ -476,13 +480,13 @@ jQuery(function () {
$(document).on('click', '.mes_hide', async function () { $(document).on('click', '.mes_hide', async function () {
const messageBlock = $(this).closest('.mes'); const messageBlock = $(this).closest('.mes');
const messageId = Number(messageBlock.attr('mesid')); const messageId = Number(messageBlock.attr('mesid'));
await hideChatMessage(messageId, messageBlock); await hideChatMessageRange(messageId, messageId, false);
}); });
$(document).on('click', '.mes_unhide', async function () { $(document).on('click', '.mes_unhide', async function () {
const messageBlock = $(this).closest('.mes'); const messageBlock = $(this).closest('.mes');
const messageId = Number(messageBlock.attr('mesid')); const messageId = Number(messageBlock.attr('mesid'));
await unhideChatMessage(messageId, messageBlock); await hideChatMessageRange(messageId, messageId, true);
}); });
$(document).on('click', '.mes_file_delete', async function () { $(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'); const loader = $('<div></div>').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x');
container.append(loader); container.append(loader);
$('body').append(container); $('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. //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 () { $('#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}`) $(`#${ELEMENT_ID}`)
//only fade out the spinner and replace with login screen
.animate({ opacity: 0 }, 300, function () { .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(); $(`#${ELEMENT_ID}`).remove();
}); });
}); });
@ -25,4 +29,7 @@ export function hideLoader() {
'filter': 'blur(15px)', 'filter': 'blur(15px)',
'opacity': '0', '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') { 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(); const { messageLogprobs, continueFrom } = getActiveMessageLogprobData();
@ -261,7 +261,7 @@ function onPrefixClicked() {
function checkGenerateReady() { function checkGenerateReady() {
if (is_send_press) { 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 false;
} }
return true; return true;
@ -292,13 +292,13 @@ function onToggleLogprobsPanel() {
} else { } else {
logprobsViewer.addClass('resizing'); logprobsViewer.addClass('resizing');
logprobsViewer.transition({ logprobsViewer.transition({
opacity: 0.0, opacity: 0.0,
duration: animation_duration, duration: animation_duration,
}, },
async function () { async function () {
await delay(50); await delay(50);
logprobsViewer.removeClass('resizing'); logprobsViewer.removeClass('resizing');
}); });
setTimeout(function () { setTimeout(function () {
logprobsViewer.hide(); logprobsViewer.hide();
}, animation_duration); }, animation_duration);
@ -407,7 +407,7 @@ export function saveLogprobsForActiveMessage(logprobs, continueFrom) {
messageLogprobs: logprobs, messageLogprobs: logprobs,
continueFrom, continueFrom,
hash: getMessageHash(chat[msgId]), hash: getMessageHash(chat[msgId]),
} };
state.messageLogprobs.set(data.hash, data); state.messageLogprobs.set(data.hash, data);
@ -458,7 +458,7 @@ function convertTokenIdLogprobsToText(input) {
// Flatten unique token IDs across all logprobs // Flatten unique token IDs across all logprobs
const tokenIds = Array.from(new Set(input.flatMap(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 // Submit token IDs to tokenizer to get token text, then build ID->text map
@ -469,7 +469,7 @@ function convertTokenIdLogprobsToText(input) {
input.forEach(logprobs => { input.forEach(logprobs => {
logprobs.token = tokenIdText.get(logprobs.token); logprobs.token = tokenIdText.get(logprobs.token);
logprobs.topLogprobs = logprobs.topLogprobs.map(([token, logprob]) => 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) => { const shouldSquash = (message) => {
return !excludeList.includes(message.identifier) && message.role === 'system' && !message.name; return !excludeList.includes(message.identifier) && message.role === 'system' && !message.name;
} };
if (shouldSquash(message)) { if (shouldSquash(message)) {
if (lastMessage && shouldSquash(lastMessage)) { if (lastMessage && shouldSquash(lastMessage)) {

View File

@ -219,7 +219,7 @@ export function callGenericPopup(text, type, inputValue = '', { okButton, cancel
text, text,
type, type,
inputValue, inputValue,
{ okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling }, { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cancelButton },
); );
return popup.show(); 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 { SlashCommandParserError } from './slash-commands/SlashCommandParserError.js';
import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js'; import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
import { getMessageTimeStamp } from './RossAscends-mods.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 { getContext, saveMetadataDebounced } from './extensions.js';
import { getRegexedString, regex_placement } from './extensions/regex/engine.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'; import { findGroupMemberId, groups, is_group_generating, openGroupById, resetSelectedGroup, saveGroupChat, selected_group } from './group-chats.js';
@ -937,16 +937,7 @@ async function hideMessageCallback(_, arg) {
return; return;
} }
for (let messageId = range.start; messageId <= range.end; messageId++) { await hideChatMessageRange(range.start, range.end, false);
const messageBlock = $(`.mes[mesid="${messageId}"]`);
if (!messageBlock.length) {
console.warn(`WARN: No message found with ID ${messageId}`);
return;
}
await hideChatMessage(messageId, messageBlock);
}
} }
async function unhideMessageCallback(_, arg) { async function unhideMessageCallback(_, arg) {
@ -962,17 +953,7 @@ async function unhideMessageCallback(_, arg) {
return ''; return '';
} }
for (let messageId = range.start; messageId <= range.end; messageId++) { await hideChatMessageRange(range.start, range.end, true);
const messageBlock = $(`.mes[mesid="${messageId}"]`);
if (!messageBlock.length) {
console.warn(`WARN: No message found with ID ${messageId}`);
return '';
}
await unhideChatMessage(messageId, messageBlock);
}
return ''; 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/character-group-overlay.css);
@import url(css/file-form.css); @import url(css/file-form.css);
@import url(css/logprobs.css); @import url(css/logprobs.css);
@import url(css/accounts.css);
:root { :root {
--doc-height: 100%; --doc-height: 100%;
@ -456,7 +457,7 @@ body.reduced-motion #bg_custom {
} }
#bg1 { #bg1 {
background-image: url('backgrounds/__transparent.png'); background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=');
z-index: -3; z-index: -3;
} }
@ -3498,7 +3499,7 @@ a {
} }
#ui_language_select { #ui_language_select {
width: 10em; width: 8em;
} }
#extensions_settings .inline-drawer-toggle.inline-drawer-header:hover, #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 #!/usr/bin/env node
// native node modules // native node modules
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
@ -19,8 +18,10 @@ const doubleCsrf = require('csrf-csrf').doubleCsrf;
const express = require('express'); const express = require('express');
const compression = require('compression'); const compression = require('compression');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const cookieSession = require('cookie-session');
const multer = require('multer'); const multer = require('multer');
const responseTime = require('response-time'); const responseTime = require('response-time');
const helmet = require('helmet').default;
// net related library imports // net related library imports
const net = require('net'); const net = require('net');
@ -33,6 +34,7 @@ util.inspect.defaultOptions.maxStringLength = null;
util.inspect.defaultOptions.depth = 4; util.inspect.defaultOptions.depth = 4;
// local library imports // local library imports
const userModule = require('./src/users');
const basicAuthMiddleware = require('./src/middleware/basicAuth'); const basicAuthMiddleware = require('./src/middleware/basicAuth');
const whitelistMiddleware = require('./src/middleware/whitelist'); const whitelistMiddleware = require('./src/middleware/whitelist');
const contentManager = require('./src/endpoints/content-manager'); const contentManager = require('./src/endpoints/content-manager');
@ -60,6 +62,7 @@ const DEFAULT_PORT = 8000;
const DEFAULT_AUTORUN = false; const DEFAULT_AUTORUN = false;
const DEFAULT_LISTEN = false; const DEFAULT_LISTEN = false;
const DEFAULT_CORS_PROXY = false; const DEFAULT_CORS_PROXY = false;
const DEFAULT_WHITELIST = true;
const cliArguments = yargs(hideBin(process.argv)) const cliArguments = yargs(hideBin(process.argv))
.usage('Usage: <your-start-script> <command> [options]') .usage('Usage: <your-start-script> <command> [options]')
@ -95,6 +98,14 @@ const cliArguments = yargs(hideBin(process.argv))
type: 'string', type: 'string',
default: 'certs/privkey.pem', default: 'certs/privkey.pem',
describe: 'Path to your private key file.', 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(); }).parseSync();
// change all relative paths // change all relative paths
@ -103,6 +114,9 @@ const serverDirectory = __dirname;
process.chdir(serverDirectory); process.chdir(serverDirectory);
const app = express(); const app = express();
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(compression()); app.use(compression());
app.use(responseTime()); 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 autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN)) && !cliArguments.ssl;
const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN); const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN);
const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY); 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 basicAuthMode = getConfigValue('basicAuthMode', false);
const enableAccounts = getConfigValue('enableUserAccounts', false);
const { DIRECTORIES, UPLOADS_PATH } = require('./src/constants'); const { UPLOADS_PATH } = require('./src/constants');
// CORS Settings // // CORS Settings //
const CORS = cors({ const CORS = cors({
@ -124,41 +141,7 @@ app.use(CORS);
if (listen && basicAuthMode) app.use(basicAuthMiddleware); if (listen && basicAuthMode) app.use(basicAuthMiddleware);
app.use(whitelistMiddleware(listen)); app.use(whitelistMiddleware(enableWhitelist, 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',
});
});
}
if (enableCorsProxy) { if (enableCorsProxy) {
const bodyParser = require('body-parser'); 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(express.static(process.cwd() + '/public', {}));
app.use('/backgrounds', (req, res) => { // Public API
const filePath = decodeURIComponent(path.join(process.cwd(), DIRECTORIES.backgrounds, req.url.replace(/%20/g, ' '))); app.use('/api/users', require('./src/endpoints/users-public').router);
fs.readFile(filePath, (err, data) => {
if (err) {
res.status(404).send('File not found');
return;
}
//res.contentType('image/jpeg');
res.send(data);
});
});
app.use('/characters', (req, res) => { // Everything below this line requires authentication
const filePath = decodeURIComponent(path.join(process.cwd(), DIRECTORIES.characters, req.url.replace(/%20/g, ' '))); app.use(userModule.requireLoginMiddleware);
fs.readFile(filePath, (err, data) => {
if (err) { // File uploads
res.status(404).send('File not found');
return;
}
res.send(data);
});
});
app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); 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) { app.get('/version', async function (_, response) {
const data = await getVersion(); const data = await getVersion();
response.send(data); 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 // 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. // 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(); await settingsEndpoint.init();
ensurePublicDirectoriesExist(); const directories = await userModule.ensurePublicDirectoriesExist();
await userModule.migrateUserData();
await contentManager.checkForNewContent(directories);
await ensureThumbnailCache(); await ensureThumbnailCache();
contentManager.checkForNewContent();
cleanUploads(); cleanUploads();
await loadTokenizers(); await loadTokenizers();
@ -551,7 +601,7 @@ async function loadPlugins() {
} }
} }
if (listen && !getConfigValue('whitelistMode', true) && !basicAuthMode) { if (listen && !enableWhitelist && !basicAuthMode) {
if (getConfigValue('securityOverride', false)) { if (getConfigValue('securityOverride', false)) {
console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); 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, 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 { SECRET_KEYS, readSecret } = require('./endpoints/secrets');
const { getConfigValue } = require('./util'); 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 ? ({ return apiKey ? ({
'X-API-KEY': 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 ? ({ return apiKey ? ({
'Authorization': `Bearer ${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 ? ({ return apiKey ? ({
'Authorization': `Bearer ${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 ? ({ return apiKey ? ({
'Authorization': `Bearer ${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 }; const baseHeaders = { ...OPENROUTER_HEADERS };
return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders; 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 ? ({ return apiKey ? ({
'X-API-KEY': 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 ? ({ return apiKey ? ({
'x-api-key': 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 ? ({ return apiKey ? ({
'Authorization': `Bearer ${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 ? ({ return apiKey ? ({
'Authorization': `Bearer ${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 ? ({ return apiKey ? ({
'Authorization': `Bearer ${apiKey}`, 'Authorization': `Bearer ${apiKey}`,
@ -96,7 +146,7 @@ function getOverrideHeaders(urlHost) {
/** /**
* Sets additional headers for the request. * 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 {object} args New request arguments
* @param {string|null} server API server for new request * @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 getHeaders = headerGetters[request.body.api_type];
const headers = getHeaders ? getHeaders() : {}; const headers = getHeaders ? getHeaders(request.user.directories) : {};
if (typeof server === 'string' && server.length > 0) { if (typeof server === 'string' && server.length > 0) {
try { try {

View File

@ -1,34 +1,62 @@
const DIRECTORIES = { const PUBLIC_DIRECTORIES = {
worlds: 'public/worlds/',
user: 'public/user',
avatars: 'public/User Avatars',
images: 'public/img/', 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/', backups: 'backups/',
quickreplies: 'public/QuickReplies',
assets: 'public/assets',
comfyWorkflows: 'public/user/workflows',
files: 'public/user/files',
sounds: 'public/sounds', 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 = [ const UNSAFE_EXTENSIONS = [
'.php', '.php',
'.exe', '.exe',
@ -270,7 +298,11 @@ const OPENROUTER_KEYS = [
]; ];
module.exports = { module.exports = {
DIRECTORIES, DEFAULT_USER,
DEFAULT_AVATAR,
SETTINGS_FILE,
PUBLIC_DIRECTORIES,
USER_DIRECTORY_TEMPLATE,
UNSAFE_EXTENSIONS, UNSAFE_EXTENSIONS,
UPLOADS_PATH, UPLOADS_PATH,
GEMINI_SAFETY, GEMINI_SAFETY,

View File

@ -39,7 +39,7 @@ router.post('/caption-image', jsonParser, async (request, response) => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'anthropic-version': '2023-06-01', '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, timeout: 0,
}); });

View File

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

View File

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

View File

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

View File

@ -516,7 +516,7 @@ llamacpp.post('/slots', jsonParser, async function (request, response) {
const baseUrl = trimV1(request.body.server_url); const baseUrl = trimV1(request.body.server_url);
let fetchResponse; let fetchResponse;
if (request.body.action === "info") { if (request.body.action === 'info') {
fetchResponse = await fetch(`${baseUrl}/slots`, { fetchResponse = await fetch(`${baseUrl}/slots`, {
method: 'GET', method: 'GET',
timeout: 0, timeout: 0,
@ -525,7 +525,7 @@ llamacpp.post('/slots', jsonParser, async function (request, response) {
if (!/^\d+$/.test(request.body.id_slot)) { if (!/^\d+$/.test(request.body.id_slot)) {
return response.sendStatus(400); return response.sendStatus(400);
} }
if (request.body.action !== "erase" && !request.body.filename) { if (request.body.action !== 'erase' && !request.body.filename) {
return response.sendStatus(400); return response.sendStatus(400);
} }
@ -534,7 +534,7 @@ llamacpp.post('/slots', jsonParser, async function (request, response) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
timeout: 0, timeout: 0,
body: JSON.stringify({ 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 sanitize = require('sanitize-filename');
const { jsonParser, urlencodedParser } = require('../express-common'); const { jsonParser, urlencodedParser } = require('../express-common');
const { DIRECTORIES, UPLOADS_PATH } = require('../constants'); const { UPLOADS_PATH } = require('../constants');
const { invalidateThumbnail } = require('./thumbnails'); const { invalidateThumbnail } = require('./thumbnails');
const { getImages } = require('../util'); const { getImages } = require('../util');
const router = express.Router(); const router = express.Router();
router.post('/all', jsonParser, function (request, response) { router.post('/all', jsonParser, function (request, response) {
var images = getImages('public/backgrounds'); var images = getImages(request.user.directories.backgrounds);
response.send(JSON.stringify(images)); response.send(JSON.stringify(images));
}); });
router.post('/delete', jsonParser, function (request, response) { router.post('/delete', jsonParser, function (request, response) {
@ -24,7 +23,7 @@ router.post('/delete', jsonParser, function (request, response) {
return response.sendStatus(403); 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)) { if (!fs.existsSync(fileName)) {
console.log('BG file not found'); console.log('BG file not found');
@ -32,15 +31,15 @@ router.post('/delete', jsonParser, function (request, response) {
} }
fs.rmSync(fileName); fs.rmSync(fileName);
invalidateThumbnail('bg', request.body.bg); invalidateThumbnail(request.user.directories, 'bg', request.body.bg);
return response.send('ok'); return response.send('ok');
}); });
router.post('/rename', jsonParser, function (request, response) { router.post('/rename', jsonParser, function (request, response) {
if (!request.body) return response.sendStatus(400); if (!request.body) return response.sendStatus(400);
const oldFileName = path.join(DIRECTORIES.backgrounds, sanitize(request.body.old_bg)); const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));
const newFileName = path.join(DIRECTORIES.backgrounds, sanitize(request.body.new_bg)); const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg));
if (!fs.existsSync(oldFileName)) { if (!fs.existsSync(oldFileName)) {
console.log('BG file not found'); console.log('BG file not found');
@ -53,7 +52,7 @@ router.post('/rename', jsonParser, function (request, response) {
} }
fs.renameSync(oldFileName, newFileName); fs.renameSync(oldFileName, newFileName);
invalidateThumbnail('bg', request.body.old_bg); invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);
return response.send('ok'); return response.send('ok');
}); });
@ -64,8 +63,8 @@ router.post('/upload', urlencodedParser, function (request, response) {
const filename = request.file.originalname; const filename = request.file.originalname;
try { try {
fs.renameSync(img_path, path.join('public/backgrounds/', filename)); fs.renameSync(img_path, path.join(request.user.directories.backgrounds, filename));
invalidateThumbnail('bg', filename); invalidateThumbnail(request.user.directories, 'bg', filename);
response.send(filename); response.send(filename);
} catch (err) { } catch (err) {
console.error(err); console.error(err);

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