Merge pull request #2113 from SillyTavern/neo-server

Neo server
This commit is contained in:
Cohee 2024-04-21 22:36:28 +03:00 committed by GitHub
commit 80ff8383fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
164 changed files with 8062 additions and 1480 deletions

View File

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

View File

@ -42,11 +42,21 @@ module.exports = {
showdownKatex: 'readonly',
SVGInject: 'readonly',
toastr: 'readonly',
Readability: 'readonly',
isProbablyReaderable: 'readonly',
},
},
],
// There are various vendored libraries that shouldn't be linted
ignorePatterns: ['public/lib/**/*', '*.min.js', 'src/ai_horde/**/*'],
ignorePatterns: [
'public/lib/**/*',
'*.min.js',
'src/ai_horde/**/*',
'plugins/**/*',
'data/**/*',
'backups/**/*',
'node_modules/**/*',
],
rules: {
'no-unused-vars': ['error', { args: 'none' }],
'no-control-regex': 'off',

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,8 +1,12 @@
# -- NETWORK CONFIGURATION --
# -- DATA CONFIGURATION --
# Root directory for user data storage
dataRoot: ./data
# -- SERVER CONFIGURATION --
# Listen for incoming connections
listen: false
# Server port
port: 8000
# -- SECURITY CONFIGURATION --
# Toggle whitelist mode
whitelistMode: true
# Whitelist of allowed IP addresses
@ -16,7 +20,15 @@ basicAuthUser:
password: "password"
# Enables CORS proxy middleware
enableCorsProxy: false
# Disable security checks - NOT RECOMMENDED
# 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 CSRF protection - NOT RECOMMENDED
disableCsrfProtection: false
# Disable startup security checks - NOT RECOMMENDED
securityOverride: false
# -- ADVANCED CONFIGURATION --
# Open the browser automatically

View File

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 B

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 487 KiB

After

Width:  |  Height:  |  Size: 487 KiB

View File

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 307 KiB

View File

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 318 KiB

View File

Before

Width:  |  Height:  |  Size: 581 KiB

After

Width:  |  Height:  |  Size: 581 KiB

View File

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 561 KiB

View File

Before

Width:  |  Height:  |  Size: 505 KiB

After

Width:  |  Height:  |  Size: 505 KiB

View File

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 443 KiB

View File

Before

Width:  |  Height:  |  Size: 480 KiB

After

Width:  |  Height:  |  Size: 480 KiB

View File

Before

Width:  |  Height:  |  Size: 660 KiB

After

Width:  |  Height:  |  Size: 660 KiB

View File

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 371 KiB

View File

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 616 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 305 KiB

View File

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 436 KiB

View File

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

View File

Before

Width:  |  Height:  |  Size: 629 KiB

After

Width:  |  Height:  |  Size: 629 KiB

View File

Before

Width:  |  Height:  |  Size: 656 KiB

After

Width:  |  Height:  |  Size: 656 KiB

View File

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 528 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

20
index.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
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;
touch: number;
// other properties...
}
}

View File

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

947
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -60,7 +60,8 @@ function convertConfig() {
try {
console.log(color.blue('Converting config.conf to config.yaml. Your old config.conf will be renamed to config.conf.bak'));
const config = require(path.join(process.cwd(), './config.conf'));
fs.renameSync('./config.conf', './config.conf.bak');
fs.copyFileSync('./config.conf', './config.conf.bak');
fs.rmSync('./config.conf');
fs.writeFileSync('./config.yaml', yaml.stringify(config));
console.log(color.green('Conversion successful. Please check your config.yaml and fix it if necessary.'));
} catch (error) {
@ -106,7 +107,6 @@ function addMissingConfigValues() {
*/
function createDefaultFiles() {
const files = {
settings: './public/settings.json',
config: './config.yaml',
user: './public/css/user.css',
};
@ -167,29 +167,6 @@ function copyWasmFiles() {
}
}
/**
* Moves the custom background into settings.json.
*/
function migrateBackground() {
if (!fs.existsSync('./public/css/bg_load.css')) return;
const bgCSS = fs.readFileSync('./public/css/bg_load.css', 'utf-8');
const bgMatch = /url\('([^']*)'\)/.exec(bgCSS);
if (!bgMatch) return;
const bgFilename = bgMatch[1].replace('../backgrounds/', '');
const settings = fs.readFileSync('./public/settings.json', 'utf-8');
const settingsJSON = JSON.parse(settings);
if (Object.hasOwn(settingsJSON, 'background')) {
console.log(color.yellow('Both bg_load.css and the "background" setting exist. Please delete bg_load.css manually.'));
return;
}
settingsJSON.background = { name: bgFilename, url: `url('backgrounds/${bgFilename}')` };
fs.writeFileSync('./public/settings.json', JSON.stringify(settingsJSON, null, 4));
fs.rmSync('./public/css/bg_load.css');
}
try {
// 0. Convert config.conf to config.yaml
convertConfig();
@ -199,8 +176,6 @@ try {
copyWasmFiles();
// 3. Add missing config values
addMissingConfigValues();
// 4. Migrate bg_load.css to settings.json
migrateBackground();
} catch (error) {
console.error(error);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

@ -0,0 +1,44 @@
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: 30%;
cursor: pointer;
margin: 5px 0;
transition: background-color 0.15s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
}
body.login .userSelect .userName,
body.login .userSelect .userHandle {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.login .userSelect:hover {
background-color: var(--black30a);
}

View File

@ -102,6 +102,14 @@
justify-content: space-between;
}
.justifySpaceEvenly {
justify-content: space-evenly;
}
.justifySpaceAround {
justify-content: space-around;
}
.alignitemsflexstart {
align-items: flex-start !important;
}
@ -491,6 +499,10 @@ textarea:disabled {
font-size: calc(var(--mainFontSize) * 1.2) !important;
}
.fontsize90p {
font-size: calc(var(--mainFontSize) * 0.9) !important;
}
.fontsize80p {
font-size: calc(var(--mainFontSize) * 0.8) !important;
}

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

@ -89,6 +89,7 @@
<script type="module" src="scripts/bulk-edit.js"></script>
<script type="module" src="scripts/cfg-scale.js"></script>
<script type="module" src="scripts/chats.js"></script>
<script type="module" src="scripts/user.js"></script>
<title>SillyTavern</title>
</head>
@ -3514,7 +3515,21 @@
<small id="version_display"></small>
</div>
<div name="UserSettingsRowTwo" class="flex-container flexFlowRow">
<textarea id="settingsSearch" class="textarea_compact wide100p" rows="1" placeholder="Search Settings" data-i18n="[placeholder]Search Settings"></textarea>
<div id="account_controls" class="flex-container">
<div id="account_button" class="margin0 menu_button_icon menu_button">
<i class="fa-fw fa-solid fa-user-shield"></i>
<span data-i18n="Account">Account</span>
</div>
<div id="admin_button" class="margin0 menu_button_icon menu_button" >
<i class="fa-fw fa-solid fa-user-tie"></i>
<span data-i18n="Admin Panel">Admin Panel</span>
</div>
<div id="logout_button" class="margin0 menu_button_icon menu_button">
<i class="fa-fw fa-solid fa-right-from-bracket"></i>
<span data-i18n="Logout">Logout</span>
</div>
</div>
<textarea id="settingsSearch" class="textarea_compact flex1" rows="1" placeholder="Search Settings" data-i18n="[placeholder]Search Settings"></textarea>
</div>
</div>
<div id="user-settings-block-content" class="flex-container spaceEvenly">
@ -3957,8 +3972,8 @@
<span class="fa-solid fa-circle-question note-link-span"></span>
</a>
</label>
<label class="checkbox_label" for="forbid_external_images" title="Disalow embedded media from other domains in chat messages." data-i18n="[title]Disalow embedded media from other domains in chat messages">
<input id="forbid_external_images" type="checkbox" />
<label class="checkbox_label" for="forbid_external_media" title="Disalow embedded media from other domains in chat messages." data-i18n="[title]Disalow embedded media from other domains in chat messages">
<input id="forbid_external_media" type="checkbox" />
<span data-i18n="Forbid External Media">Forbid External Media</span>
</label>
<label data-newbie-hidden class="checkbox_label" for="allow_name2_display">
@ -5374,17 +5389,16 @@
Enable simple UI mode
</span>
</label>
<h3 data-i18n="Your Persona">
Your Persona
</h3>
<div class="justifyLeft margin-bot-10px">
<span data-i18n="Before you get started, you must select a user name.">
Before you get started, you must select a user name.
<span data-i18n="Before you get started, you must select a persona name.">
Before you get started, you must select a persona name.
</span>
This can be changed at any time via the <code><i class="fa-solid fa-face-smile"></i></code> icon.
</div>
<h4 data-i18n="UI Language:">UI Language:</h4>
<select name="onboarding_ui_language">
<option value="en">English</option>
</select>
<h4 data-i18n="User Name:">User Name:</h4>
<h4 data-i18n="Persona Name:">Persona Name:</h4>
</div>
</div>
<div id="group_member_template" class="template_element">

1
public/lib/epub.min.js vendored Normal file

File diff suppressed because one or more lines are too long

13
public/lib/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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 an Account
</h3>
<h3 id="discreetLoginPrompt">
Enter Login Details
</h3>
<div id="userListBlock" class="wide100p">
<div id="userList" class="flex-container justifySpaceEvenly"></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

@ -202,7 +202,7 @@ import {
instruct_presets,
selectContextPreset,
} from './scripts/instruct-mode.js';
import { applyLocale, initLocales } from './scripts/i18n.js';
import { initLocales } from './scripts/i18n.js';
import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';
import { createPersona, initPersonas, selectCurrentPersona, setPersonaDescription, updatePersonaNameIfExists } from './scripts/personas.js';
import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js';
@ -212,8 +212,10 @@ import { loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermati
import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId } from './scripts/chats.js';
import { initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js';
import { callGenericPopup } from './scripts/popup.js';
import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
import { ScraperManager } from './scripts/scrapers.js';
//exporting functions and vars for mods
export {
@ -446,6 +448,7 @@ export const event_types = {
CHARACTER_DELETED: 'characterDeleted',
CHARACTER_DUPLICATED: 'character_duplicated',
SMOOTH_STREAM_TOKEN_RECEIVED: 'smooth_stream_token_received',
FILE_ATTACHMENT_DELETED: 'file_attachment_deleted',
};
export const eventSource = new EventEmitter();
@ -666,13 +669,15 @@ async function getSystemMessages() {
registerPromptManagerMigration();
$(document).ajaxError(function myErrorHandler(_, xhr) {
// Cohee: CSRF doesn't error out in multiple tabs anymore, so this is unnecessary
/*
if (xhr.status == 403) {
toastr.warning(
'doubleCsrf errors in console are NORMAL in this case. If you want to run ST in multiple tabs, start the server with --disableCsrf option.',
'Looks like you\'ve opened SillyTavern in another browser tab',
{ timeOut: 0, extendedTimeOut: 0, preventDuplicates: true },
);
}
} */
});
async function getClientVersion() {
@ -1501,7 +1506,7 @@ function getCharacterSource(chId = this_chid) {
}
async function getCharacters() {
var response = await fetch('/api/characters/all', {
const response = await fetch('/api/characters/all', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
@ -1509,11 +1514,9 @@ async function getCharacters() {
}),
});
if (response.ok === true) {
var getData = ''; //RossAscends: reset to force array to update to account for deleted character.
getData = await response.json();
const load_ch_count = Object.getOwnPropertyNames(getData);
for (var i = 0; i < load_ch_count.length; i++) {
characters[i] = [];
characters.splice(0, characters.length);
const getData = await response.json();
for (let i = 0; i < getData.length; i++) {
characters[i] = getData[i];
characters[i]['name'] = DOMPurify.sanitize(characters[i]['name']);
@ -6079,7 +6082,7 @@ async function doOnboarding(avatarId) {
template.find('input[name="enable_simple_mode"]').on('input', function () {
simpleUiMode = $(this).is(':checked');
});
var userName = await callPopup(template, 'input', name1);
let userName = await callPopup(template, 'input', currentUser?.name || name1);
if (userName) {
userName = userName.replace('\n', ' ');
@ -6133,6 +6136,8 @@ async function getSettings() {
$('#your_name').val(name1);
}
await setUserControls(data.enable_accounts);
// Allow subscribers to mutate settings
eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);
@ -7090,10 +7095,10 @@ function onScenarioOverrideRemoveClick() {
* @param {string} type
* @param {string} inputValue - Value to set the input to.
* @param {PopupOptions} options - Options for the popup.
* @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean }} PopupOptions - Options for the popup.
* @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup.
* @returns
*/
function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) {
function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
dialogueCloseStop = true;
if (type) {
popup_type = type;
@ -7150,7 +7155,7 @@ function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, a
crop_data = undefined;
$('#avatarToCrop').cropper({
aspectRatio: 2 / 3,
aspectRatio: cropAspect ?? 2 / 3,
autoCropArea: 1,
viewMode: 2,
rotatable: false,
@ -7362,47 +7367,6 @@ export function cancelTtsPlay() {
}
}
async function deleteMessageImage() {
const value = await callPopup('<h3>Delete image from message?<br>This action can\'t be undone.</h3>', 'confirm');
if (!value) {
return;
}
const mesBlock = $(this).closest('.mes');
const mesId = mesBlock.attr('mesid');
const message = chat[mesId];
delete message.extra.image;
delete message.extra.inline_image;
mesBlock.find('.mes_img_container').removeClass('img_extra');
mesBlock.find('.mes_img').attr('src', '');
await saveChatConditional();
}
function enlargeMessageImage() {
const mesBlock = $(this).closest('.mes');
const mesId = mesBlock.attr('mesid');
const message = chat[mesId];
const imgSrc = message?.extra?.image;
const title = message?.extra?.title;
if (!imgSrc) {
return;
}
const img = document.createElement('img');
img.classList.add('img_enlarged');
img.src = imgSrc;
const imgContainer = $('<div><pre><code></code></pre></div>');
imgContainer.prepend(img);
imgContainer.addClass('img_enlarged_container');
imgContainer.find('code').addClass('txt').text(title);
const titleEmpty = !title || title.trim().length === 0;
imgContainer.find('pre').toggle(!titleEmpty);
addCopyToCodeBlocks(imgContainer);
callPopup(imgContainer, 'text', '', { wide: true, large: true });
}
function updateAlternateGreetingsHintVisibility(root) {
const numberOfGreetings = root.find('.alternate_greetings_list .alternate_greeting').length;
$(root).find('.alternate_grettings_hint').toggle(numberOfGreetings == 0);
@ -7798,6 +7762,7 @@ window['SillyTavern'].getContext = function () {
*/
renderExtensionTemplate: renderExtensionTemplate,
renderExtensionTemplateAsync: renderExtensionTemplateAsync,
registerDataBankScraper: ScraperManager.registerDataBankScraper,
callPopup: callPopup,
callGenericPopup: callGenericPopup,
mainApi: main_api,
@ -10162,6 +10127,7 @@ jQuery(async function () {
'#character_cross',
'#avatar-and-name-block',
'#shadow_popup',
'.shadow_popup',
'#world_popup',
'.ui-widget',
'.text_pole',
@ -10397,9 +10363,6 @@ jQuery(async function () {
$('#char-management-dropdown').prop('selectedIndex', 0);
});
$(document).on('click', '.mes_img_enlarge', enlargeMessageImage);
$(document).on('click', '.mes_img_delete', deleteMessageImage);
$(window).on('beforeunload', () => {
cancelTtsPlay();
if (streamingProcessor) {

View File

@ -32,7 +32,7 @@ import {
SECRET_KEYS,
secret_state,
} from './secrets.js';
import { debounce, delay, getStringHash, isValidUrl } from './utils.js';
import { debounce, getStringHash, isValidUrl } from './utils.js';
import { chat_completion_sources, oai_settings } from './openai.js';
import { getTokenCountAsync } from './tokenizers.js';
import { textgen_types, textgenerationwebui_settings as textgen_settings, getTextGenServer } from './textgen-settings.js';

View File

@ -1,4 +1,4 @@
import { characters, getCharacters, handleDeleteCharacter, callPopup, characterGroupOverlay } from '../script.js';
import { characterGroupOverlay } from '../script.js';
import { BulkEditOverlay, BulkEditOverlayState } from './BulkEditOverlay.js';
@ -69,15 +69,6 @@ function onSelectAllButtonClick() {
}
}
/**
* Deletes the character with the given chid.
*
* @param {string} this_chid - The chid of the character to delete.
*/
async function deleteCharacter(this_chid) {
await handleDeleteCharacter('del_ch', this_chid, false);
}
/**
* Deletes all characters that have been selected via the bulk checkboxes.
*/

View File

@ -18,6 +18,8 @@ import {
saveSettingsDebounced,
showSwipeButtons,
this_chid,
saveChatConditional,
chat_metadata,
} from '../script.js';
import { selected_group } from './group-chats.js';
import { power_user } from './power-user.js';
@ -25,22 +27,93 @@ import {
extractTextFromHTML,
extractTextFromMarkdown,
extractTextFromPDF,
extractTextFromEpub,
getBase64Async,
getStringHash,
humanFileSize,
saveBase64AsFile,
extractTextFromOffice,
} from './utils.js';
import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
import { ScraperManager } from './scrapers.js';
/**
* @typedef {Object} FileAttachment
* @property {string} url File URL
* @property {number} size File size
* @property {string} name File name
* @property {number} created Timestamp
* @property {string} [text] File text
*/
/**
* @typedef {function} ConverterFunction
* @param {File} file File object
* @returns {Promise<string>} Converted file text
*/
const fileSizeLimit = 1024 * 1024 * 10; // 10 MB
const ATTACHMENT_SOURCE = {
GLOBAL: 'global',
CHAT: 'chat',
CHARACTER: 'character',
};
/**
* @type {Record<string, ConverterFunction>} File converters
*/
const converters = {
'application/pdf': extractTextFromPDF,
'text/html': extractTextFromHTML,
'text/markdown': extractTextFromMarkdown,
'application/epub+zip': extractTextFromEpub,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': extractTextFromOffice,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': extractTextFromOffice,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': extractTextFromOffice,
'application/vnd.oasis.opendocument.text': extractTextFromOffice,
'application/vnd.oasis.opendocument.presentation': extractTextFromOffice,
'application/vnd.oasis.opendocument.spreadsheet': extractTextFromOffice,
};
/**
* Finds a matching key in the converters object.
* @param {string} type MIME type
* @returns {string} Matching key
*/
function findConverterKey(type) {
return Object.keys(converters).find((key) => {
// Match exact type
if (type === key) {
return true;
}
// Match wildcards
if (key.endsWith('*')) {
return type.startsWith(key.substring(0, key.length - 1));
}
return false;
});
}
/**
* Determines if the file type has a converter function.
* @param {string} type MIME type
* @returns {boolean} True if the file type is convertible, false otherwise.
*/
function isConvertible(type) {
return Object.keys(converters).includes(type);
return Boolean(findConverterKey(type));
}
/**
* Gets the converter function for a file type.
* @param {string} type MIME type
* @returns {ConverterFunction} Converter function
*/
function getConverter(type) {
const key = findConverterKey(type);
return key && converters[key];
}
/**
@ -126,7 +199,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
if (isConvertible(file.type)) {
try {
const converter = converters[file.type];
const converter = getConverter(file.type);
const fileText = await converter(file);
base64Data = window.btoa(unescape(encodeURIComponent(fileText)));
} catch (error) {
@ -145,6 +218,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
url: fileUrl,
size: file.size,
name: file.name,
created: Date.now(),
};
}
@ -275,9 +349,9 @@ async function onFileAttach() {
* @param {number} messageId Message ID
*/
async function deleteMessageFile(messageId) {
const confirm = await callPopup('Are you sure you want to delete this file?', 'confirm');
const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM);
if (!confirm) {
if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
console.debug('Delete file cancelled');
return;
}
@ -289,11 +363,15 @@ async function deleteMessageFile(messageId) {
return;
}
const url = message.extra.file.url;
delete message.extra.file;
$(`.mes[mesid="${messageId}"] .mes_file_container`).remove();
saveChatDebounced();
await saveChatConditional();
await deleteFileFromServer(url);
}
/**
* Opens file from message in a modal.
* @param {number} messageId Message ID
@ -306,14 +384,7 @@ async function viewMessageFile(messageId) {
return;
}
const fileText = messageFile.text || (await getFileAttachment(messageFile.url));
const modalTemplate = $('<div><pre><code></code></pre></div>');
modalTemplate.find('code').addClass('txt').text(fileText);
modalTemplate.addClass('file_modal');
addCopyToCodeBlocks(modalTemplate);
callPopup(modalTemplate, 'text', '', { wide: true, large: true });
await openFilePopup(messageFile);
}
/**
@ -348,7 +419,7 @@ function embedMessageFile(messageId, messageBlock) {
await populateFileAttachment(message, 'embed_file_input');
appendMediaToMessage(message, messageBlock);
saveChatDebounced();
await saveChatConditional();
}
}
@ -363,7 +434,7 @@ export async function appendFileContent(message, messageText) {
const fileText = message.extra.file.text || (await getFileAttachment(message.extra.file.url));
if (fileText) {
const fileWrapped = `\`\`\`\n${fileText}\n\`\`\`\n\n`;
const fileWrapped = `${fileText}\n\n`;
message.extra.fileLength = fileWrapped.length;
messageText = fileWrapped + messageText;
}
@ -395,7 +466,7 @@ export function decodeStyleTags(text) {
return text.replaceAll(styleDecodeRegex, (_, style) => {
try {
let styleCleaned = unescape(style).replaceAll(/<br\/>/g, '');
let styleCleaned = unescape(style).replaceAll(/<br\/>/g, '');
const ast = css.parse(styleCleaned);
const rules = ast?.stylesheet?.rules;
if (rules) {
@ -436,8 +507,8 @@ async function openExternalMediaOverridesDialog() {
}
const template = $('#forbid_media_override_template > .forbid_media_override').clone();
template.find('.forbid_media_global_state_forbidden').toggle(power_user.forbid_external_images);
template.find('.forbid_media_global_state_allowed').toggle(!power_user.forbid_external_images);
template.find('.forbid_media_global_state_forbidden').toggle(power_user.forbid_external_media);
template.find('.forbid_media_global_state_allowed').toggle(!power_user.forbid_external_media);
if (power_user.external_media_allowed_overrides.includes(entityId)) {
template.find('#forbid_media_override_allowed').prop('checked', true);
@ -463,7 +534,7 @@ export function getCurrentEntityId() {
export function isExternalMediaAllowed() {
const entityId = getCurrentEntityId();
if (!entityId) {
return !power_user.forbid_external_images;
return !power_user.forbid_external_media;
}
if (power_user.external_media_allowed_overrides.includes(entityId)) {
@ -474,7 +545,518 @@ export function isExternalMediaAllowed() {
return false;
}
return !power_user.forbid_external_images;
return !power_user.forbid_external_media;
}
function enlargeMessageImage() {
const mesBlock = $(this).closest('.mes');
const mesId = mesBlock.attr('mesid');
const message = chat[mesId];
const imgSrc = message?.extra?.image;
const title = message?.extra?.title;
if (!imgSrc) {
return;
}
const img = document.createElement('img');
img.classList.add('img_enlarged');
img.src = imgSrc;
const imgContainer = $('<div><pre><code></code></pre></div>');
imgContainer.prepend(img);
imgContainer.addClass('img_enlarged_container');
imgContainer.find('code').addClass('txt').text(title);
const titleEmpty = !title || title.trim().length === 0;
imgContainer.find('pre').toggle(!titleEmpty);
addCopyToCodeBlocks(imgContainer);
callGenericPopup(imgContainer, POPUP_TYPE.TEXT, '', { wide: true, large: true });
}
async function deleteMessageImage() {
const value = await callGenericPopup('<h3>Delete image from message?<br>This action can\'t be undone.</h3>', POPUP_TYPE.CONFIRM);
if (value !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
const mesBlock = $(this).closest('.mes');
const mesId = mesBlock.attr('mesid');
const message = chat[mesId];
delete message.extra.image;
delete message.extra.inline_image;
mesBlock.find('.mes_img_container').removeClass('img_extra');
mesBlock.find('.mes_img').attr('src', '');
await saveChatConditional();
}
/**
* Deletes file from the server.
* @param {string} url Path to the file on the server
* @returns {Promise<boolean>} True if file was deleted, false otherwise.
*/
async function deleteFileFromServer(url) {
try {
const result = await fetch('/api/files/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ path: url }),
});
if (!result.ok) {
const error = await result.text();
throw new Error(error);
}
await eventSource.emit(event_types.FILE_ATTACHMENT_DELETED, url);
return true;
} catch (error) {
toastr.error(String(error), 'Could not delete file');
console.error('Could not delete file', error);
return false;
}
}
/**
* Opens file attachment in a modal.
* @param {FileAttachment} attachment File attachment
*/
async function openFilePopup(attachment) {
const fileText = attachment.text || (await getFileAttachment(attachment.url));
const modalTemplate = $('<div><pre><code></code></pre></div>');
modalTemplate.find('code').addClass('txt').text(fileText);
modalTemplate.addClass('file_modal').addClass('textarea_compact').addClass('fontsize90p');
addCopyToCodeBlocks(modalTemplate);
callGenericPopup(modalTemplate, POPUP_TYPE.TEXT, '', { wide: true, large: true });
}
/**
* Edit a file attachment in a notepad-like modal.
* @param {FileAttachment} attachment Attachment to edit
* @param {string} source Attachment source
* @param {function} callback Callback function
*/
async function editAttachment(attachment, source, callback) {
const originalFileText = attachment.text || (await getFileAttachment(attachment.url));
const template = $(await renderExtensionTemplateAsync('attachments', 'notepad'));
let editedFileText = originalFileText;
template.find('[name="notepadFileContent"]').val(editedFileText).on('input', function () {
editedFileText = String($(this).val());
});
let editedFileName = attachment.name;
template.find('[name="notepadFileName"]').val(editedFileName).on('input', function () {
editedFileName = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: true, large: true, okButton: 'Save', cancelButton: 'Cancel' });
if (result !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
if (editedFileText === originalFileText && editedFileName === attachment.name) {
return;
}
const nullCallback = () => { };
await deleteAttachment(attachment, source, nullCallback, false);
const file = new File([editedFileText], editedFileName, { type: 'text/plain' });
await uploadFileAttachmentToServer(file, source);
callback();
}
/**
* Deletes an attachment from the server and the chat.
* @param {FileAttachment} attachment Attachment to delete
* @param {string} source Source of the attachment
* @param {function} callback Callback function
* @param {boolean} [confirm=true] If true, show a confirmation dialog
* @returns {Promise<void>} A promise that resolves when the attachment is deleted.
*/
async function deleteAttachment(attachment, source, callback, confirm = true) {
if (confirm) {
const result = await callGenericPopup('Are you sure you want to delete this attachment?', POPUP_TYPE.CONFIRM);
if (result !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
}
ensureAttachmentsExist();
switch (source) {
case 'global':
extension_settings.attachments = extension_settings.attachments.filter((a) => a.url !== attachment.url);
saveSettingsDebounced();
break;
case 'chat':
chat_metadata.attachments = chat_metadata.attachments.filter((a) => a.url !== attachment.url);
saveMetadataDebounced();
break;
case 'character':
extension_settings.character_attachments[characters[this_chid]?.avatar] = extension_settings.character_attachments[characters[this_chid]?.avatar].filter((a) => a.url !== attachment.url);
break;
}
await deleteFileFromServer(attachment.url);
callback();
}
/**
* Opens the attachment manager.
*/
async function openAttachmentManager() {
/**
* Renders a list of attachments.
* @param {FileAttachment[]} attachments List of attachments
* @param {string} source Source of the attachments
*/
async function renderList(attachments, source) {
/**
* Sorts attachments by sortField and sortOrder.
* @param {FileAttachment} a First attachment
* @param {FileAttachment} b Second attachment
* @returns {number} Sort order
*/
function sortFn(a, b) {
const sortValueA = a[sortField];
const sortValueB = b[sortField];
if (typeof sortValueA === 'string' && typeof sortValueB === 'string') {
return sortValueA.localeCompare(sortValueB) * (sortOrder === 'asc' ? 1 : -1);
}
return (sortValueA - sortValueB) * (sortOrder === 'asc' ? 1 : -1);
}
/**
* Filters attachments by name.
* @param {FileAttachment} a Attachment
* @returns {boolean} True if attachment matches the filter, false otherwise.
*/
function filterFn(a) {
if (!filterString) {
return true;
}
return a.name.toLowerCase().includes(filterString.toLowerCase());
}
const sources = {
[ATTACHMENT_SOURCE.GLOBAL]: '.globalAttachmentsList',
[ATTACHMENT_SOURCE.CHARACTER]: '.characterAttachmentsList',
[ATTACHMENT_SOURCE.CHAT]: '.chatAttachmentsList',
};
template.find(sources[source]).empty();
// Sort attachments by sortField and sortOrder, and apply filter
const sortedAttachmentList = attachments.slice().filter(filterFn).sort(sortFn);
for (const attachment of sortedAttachmentList) {
const attachmentTemplate = template.find('.attachmentListItemTemplate .attachmentListItem').clone();
attachmentTemplate.find('.attachmentListItemName').text(attachment.name);
attachmentTemplate.find('.attachmentListItemSize').text(humanFileSize(attachment.size));
attachmentTemplate.find('.attachmentListItemCreated').text(new Date(attachment.created).toLocaleString());
attachmentTemplate.find('.viewAttachmentButton').on('click', () => openFilePopup(attachment));
attachmentTemplate.find('.editAttachmentButton').on('click', () => editAttachment(attachment, source, renderAttachments));
attachmentTemplate.find('.deleteAttachmentButton').on('click', () => deleteAttachment(attachment, source, renderAttachments));
template.find(sources[source]).append(attachmentTemplate);
}
}
/**
* Renders buttons for the attachment manager.
*/
async function renderButtons() {
const sources = {
[ATTACHMENT_SOURCE.GLOBAL]: '.globalAttachmentsTitle',
[ATTACHMENT_SOURCE.CHARACTER]: '.characterAttachmentsTitle',
[ATTACHMENT_SOURCE.CHAT]: '.chatAttachmentsTitle',
};
const modal = template.find('.actionButtonsModal').hide();
const scrapers = ScraperManager.getDataBankScrapers();
for (const scraper of scrapers) {
const isAvailable = await ScraperManager.isScraperAvailable(scraper.id);
if (!isAvailable) {
continue;
}
const buttonTemplate = template.find('.actionButtonTemplate .actionButton').clone();
buttonTemplate.find('.actionButtonIcon').addClass(scraper.iconClass);
buttonTemplate.find('.actionButtonText').text(scraper.name);
buttonTemplate.attr('title', scraper.description);
buttonTemplate.on('click', () => {
const target = modal.attr('data-attachment-manager-target');
runScraper(scraper.id, target, renderAttachments);
});
modal.append(buttonTemplate);
}
const modalButtonData = Object.entries(sources).map(entry => {
const [source, selector] = entry;
const button = template.find(selector).find('.openActionModalButton').get(0);
if (!button) {
return;
}
const bodyListener = (e) => {
if (modal.is(':visible') && (!$(e.target).closest('.openActionModalButton').length)) {
modal.hide();
}
// Replay a click if the modal was already open by another button
if ($(e.target).closest('.openActionModalButton').length && !modal.is(':visible')) {
modal.show();
}
};
document.body.addEventListener('click', bodyListener);
const popper = Popper.createPopper(button, modal.get(0), { placement: 'bottom-end' });
button.addEventListener('click', () => {
modal.attr('data-attachment-manager-target', source);
modal.toggle();
popper.update();
});
return [popper, bodyListener];
}).filter(Boolean);
return () => {
modalButtonData.forEach(p => {
const [popper, bodyListener] = p;
popper.destroy();
document.body.removeEventListener('click', bodyListener);
});
modal.remove();
};
}
async function renderAttachments() {
/** @type {FileAttachment[]} */
const globalAttachments = extension_settings.attachments ?? [];
/** @type {FileAttachment[]} */
const chatAttachments = chat_metadata.attachments ?? [];
/** @type {FileAttachment[]} */
const characterAttachments = extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? [];
await renderList(globalAttachments, ATTACHMENT_SOURCE.GLOBAL);
await renderList(chatAttachments, ATTACHMENT_SOURCE.CHAT);
await renderList(characterAttachments, ATTACHMENT_SOURCE.CHARACTER);
const isNotCharacter = this_chid === undefined || selected_group;
const isNotInChat = getCurrentChatId() === undefined;
template.find('.characterAttachmentsBlock').toggle(!isNotCharacter);
template.find('.chatAttachmentsBlock').toggle(!isNotInChat);
const characterName = characters[this_chid]?.name || 'Anonymous';
template.find('.characterAttachmentsName').text(characterName);
const chatName = getCurrentChatId() || 'Unnamed chat';
template.find('.chatAttachmentsName').text(chatName);
}
let sortField = localStorage.getItem('DataBank_sortField') || 'created';
let sortOrder = localStorage.getItem('DataBank_sortOrder') || 'desc';
let filterString = '';
const template = $(await renderExtensionTemplateAsync('attachments', 'manager', {}));
template.find('.attachmentSearch').on('input', function () {
filterString = String($(this).val());
renderAttachments();
});
template.find('.attachmentSort').on('change', function () {
if (!(this instanceof HTMLSelectElement) || this.selectedOptions.length === 0) {
return;
}
sortField = this.selectedOptions[0].dataset.sortField;
sortOrder = this.selectedOptions[0].dataset.sortOrder;
localStorage.setItem('DataBank_sortField', sortField);
localStorage.setItem('DataBank_sortOrder', sortOrder);
renderAttachments();
});
const cleanupFn = await renderButtons();
await renderAttachments();
await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close' });
cleanupFn();
}
/**
* Runs a known scraper on a source and saves the result as an attachment.
* @param {string} scraperId Id of the scraper
* @param {string} target Target for the attachment
* @param {function} callback Callback function
* @returns {Promise<void>} A promise that resolves when the source is scraped.
*/
async function runScraper(scraperId, target, callback) {
try {
console.log(`Running scraper ${scraperId} for ${target}`);
const files = await ScraperManager.runDataBankScraper(scraperId);
if (!Array.isArray(files)) {
console.warn('Scraping returned nothing');
return;
}
if (files.length === 0) {
console.warn('Scraping returned no files');
toastr.info('No files were scraped.', 'Data Bank');
return;
}
for (const file of files) {
await uploadFileAttachmentToServer(file, target);
}
toastr.success(`Scraped ${files.length} files from ${scraperId} to ${target}.`, 'Data Bank');
callback();
}
catch (error) {
console.error('Scraping failed', error);
toastr.error('Check browser console for details.', 'Scraping failed');
}
}
/**
* Uploads a file attachment to the server.
* @param {File} file File to upload
* @param {string} target Target for the attachment
* @returns
*/
export async function uploadFileAttachmentToServer(file, target) {
const isValid = await validateFile(file);
if (!isValid) {
return;
}
let base64Data = await getBase64Async(file);
const slug = getStringHash(file.name);
const uniqueFileName = `${Date.now()}_${slug}.txt`;
if (isConvertible(file.type)) {
try {
const converter = getConverter(file.type);
const fileText = await converter(file);
base64Data = window.btoa(unescape(encodeURIComponent(fileText)));
} catch (error) {
toastr.error(String(error), 'Could not convert file');
console.error('Could not convert file', error);
}
} else {
const fileText = await file.text();
base64Data = window.btoa(unescape(encodeURIComponent(fileText)));
}
const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data);
const convertedSize = Math.round(base64Data.length * 0.75);
if (!fileUrl) {
return;
}
const attachment = {
url: fileUrl,
size: convertedSize,
name: file.name,
created: Date.now(),
};
ensureAttachmentsExist();
switch (target) {
case ATTACHMENT_SOURCE.GLOBAL:
extension_settings.attachments.push(attachment);
saveSettingsDebounced();
break;
case ATTACHMENT_SOURCE.CHAT:
chat_metadata.attachments.push(attachment);
saveMetadataDebounced();
break;
case ATTACHMENT_SOURCE.CHARACTER:
extension_settings.character_attachments[characters[this_chid]?.avatar].push(attachment);
saveSettingsDebounced();
break;
}
}
function ensureAttachmentsExist() {
if (!Array.isArray(extension_settings.attachments)) {
extension_settings.attachments = [];
}
if (!Array.isArray(chat_metadata.attachments)) {
chat_metadata.attachments = [];
}
if (this_chid !== undefined && characters[this_chid]) {
if (!extension_settings.character_attachments) {
extension_settings.character_attachments = {};
}
if (!Array.isArray(extension_settings.character_attachments[characters[this_chid].avatar])) {
extension_settings.character_attachments[characters[this_chid].avatar] = [];
}
}
}
/**
* Gets all currently available attachments.
* @returns {FileAttachment[]} List of attachments
*/
export function getDataBankAttachments() {
ensureAttachmentsExist();
const globalAttachments = extension_settings.attachments ?? [];
const chatAttachments = chat_metadata.attachments ?? [];
const characterAttachments = extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? [];
return [...globalAttachments, ...chatAttachments, ...characterAttachments];
}
/**
* Gets all attachments for a specific source.
* @param {string} source Attachment source
* @returns {FileAttachment[]} List of attachments
*/
export function getDataBankAttachmentsForSource(source) {
ensureAttachmentsExist();
switch (source) {
case ATTACHMENT_SOURCE.GLOBAL:
return extension_settings.attachments ?? [];
case ATTACHMENT_SOURCE.CHAT:
return chat_metadata.attachments ?? [];
case ATTACHMENT_SOURCE.CHARACTER:
return extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? [];
}
}
/**
* Registers a file converter function.
* @param {string} mimeType MIME type
* @param {ConverterFunction} converter Function to convert file
* @returns {void}
*/
export function registerFileConverter(mimeType, converter) {
if (typeof mimeType !== 'string' || typeof converter !== 'function') {
console.error('Invalid converter registration');
return;
}
if (Object.keys(converters).includes(mimeType)) {
console.error('Converter already registered');
return;
}
converters[mimeType] = converter;
}
jQuery(function () {
@ -507,6 +1089,11 @@ jQuery(function () {
$('#file_form_input').trigger('click');
});
// Do not change. #manageAttachments is added by extension.
$(document).on('click', '#manageAttachments', function () {
openAttachmentManager();
});
$(document).on('click', '.mes_embed', function () {
const messageBlock = $(this).closest('.mes');
const messageId = Number(messageBlock.attr('mesid'));
@ -598,6 +1185,9 @@ jQuery(function () {
reloadCurrentChat();
});
$(document).on('click', '.mes_img_enlarge', enlargeMessageImage);
$(document).on('click', '.mes_img_delete', deleteMessageImage);
$('#file_form_input').on('change', onFileAttach);
$('#file_form').on('reset', function () {
$('#file_form').addClass('displayNone');

View File

@ -145,6 +145,8 @@ const extension_settings = {
variables: {
global: {},
},
attachments: [],
character_attachments: {},
};
let modules = [];

View File

@ -0,0 +1,9 @@
<div id="attachFile" class="list-group-item flex-container flexGap5" title="Attach a file or image to a current chat.">
<div class="fa-fw fa-solid fa-paperclip extensionsMenuExtensionButton"></div>
<span data-i18n="Attach a File">Attach a File</span>
</div>
<div id="manageAttachments" class="list-group-item flex-container flexGap5" title="View global, character, or data files.">
<div class="fa-fw fa-solid fa-book-open-reader extensionsMenuExtensionButton"></div>
<span data-i18n="Open Data Bank">Open Data Bank</span>
</div>

View File

@ -0,0 +1,51 @@
<div>
<div class="flex-container flexFlowColumn">
<label for="fandomScrapeInput" data-i18n="Enter a URL or the ID of a Fandom wiki page to scrape:">
Enter a URL or the ID of a Fandom wiki page to scrape:
</label>
<small>
<span data-i18n=Examples:">Examples:</span>
<code>https://harrypotter.fandom.com/</code>
<span data-i18n="or">or</span>
<code>harrypotter</code>
</small>
<input type="text" id="fandomScrapeInput" name="fandomScrapeInput" class="text_pole" placeholder="">
</div>
<div class="flex-container flexFlowColumn">
<label for="fandomScrapeFilter">
Optional regex to pick the content by its title:
</label>
<small>
<span data-i18n="Example:">Example:</span>
<code>/(Azkaban|Weasley)/gi</code>
</small>
<input type="text" id="fandomScrapeFilter" name="fandomScrapeFilter" class="text_pole" placeholder="">
</div>
<div class="flex-container flexFlowColumn">
<label>
Output format:
</label>
<label class="checkbox_label justifyLeft" for="fandomScrapeOutputSingle">
<input id="fandomScrapeOutputSingle" type="radio" name="fandomScrapeOutput" value="single" checked>
<div class="flex-container flexFlowColumn flexNoGap">
<span data-i18n="Single file">
Single file
</span>
<small data-i18n="All articles will be concatenated into a single file.">
All articles will be concatenated into a single file.
</small>
</div>
</label>
<label class="checkbox_label justifyLeft" for="fandomScrapeOutputMulti">
<input id="fandomScrapeOutputMulti" type="radio" name="fandomScrapeOutput" value="multi">
<div class="flex-container flexFlowColumn flexNoGap">
<span data-i18n="File per article">
File per article
</span>
<small data-i18n="Each article will be saved as a separate file.">
Not recommended. Each article will be saved as a separate file.
</small>
</div>
</label>
</div>
</div>

View File

@ -0,0 +1,6 @@
import { renderExtensionTemplateAsync } from '../../extensions.js';
jQuery(async () => {
const buttons = await renderExtensionTemplateAsync('attachments', 'buttons', {});
$('#extensionsMenu').prepend(buttons);
});

View File

@ -0,0 +1,118 @@
<div class="wide100p padding5">
<h2 class="marginBot5">
<span data-i18n="Data Bank">
Data Bank
</span>
</h2>
<div data-i18n="These files will be available for extensions that support attachments (e.g. Vector Storage).">
These files will be available for extensions that support attachments (e.g. Vector Storage).
</div>
<div data-i18n="Supported file types: Plain Text, PDF, Markdown, HTML, EPUB." class="marginTopBot5">
Supported file types: Plain Text, PDF, Markdown, HTML, EPUB.
</div>
<div class="flex-container marginTopBot5">
<input type="search" id="attachmentSearch" class="attachmentSearch text_pole margin0 flex1" placeholder="Search...">
<select id="attachmentSort" class="attachmentSort text_pole margin0 flex1 textarea_compact">
<option data-sort-field="created" data-sort-order="desc" data-i18n="Date (Newest First)">
Date (Newest First)
</option>
<option data-sort-field="created" data-sort-order="asc" data-i18n="Date (Oldest First)">
Date (Oldest First)
</option>
<option data-sort-field="name" data-sort-order="asc" data-i18n="Name (A-Z)">
Name (A-Z)
</option>
<option data-sort-field="name" data-sort-order="desc" data-i18n="Name (Z-A)">
Name (Z-A)
</option>
<option data-sort-field="size" data-sort-order="asc" data-i18n="Size (Smallest First)">
Size (Smallest First)
</option>
<option data-sort-field="size" data-sort-order="desc" data-i18n="Size (Largest First)">
Size (Largest First)
</option>
</select>
</div>
<div class="justifyLeft globalAttachmentsBlock marginBot10">
<h3 class="globalAttachmentsTitle margin0 title_restorable">
<span data-i18n="Global Attachments">
Global Attachments
</span>
<div class="openActionModalButton menu_button menu_button_icon">
<i class="fa-solid fa-plus"></i>
<span data-i18n="Add">Add</span>
</div>
</h3>
<small data-i18n="These files are available for all characters in all chats.">
These files are available for all characters in all chats.
</small>
<div class="globalAttachmentsList attachmentsList"></div>
<hr>
</div>
<div class="justifyLeft characterAttachmentsBlock marginBot10">
<h3 class="characterAttachmentsTitle margin0 title_restorable">
<span data-i18n="Character Attachments">
Character Attachments
</span>
<div class="openActionModalButton menu_button menu_button_icon">
<i class="fa-solid fa-plus"></i>
<span data-i18n="Add">Add</span>
</div>
</h3>
<div class="flex-container flexFlowColumn">
<strong><small class="characterAttachmentsName"></small></strong>
<small>
<span data-i18n="These files are available the current character in all chats they are in.">
These files are available the current character in all chats they are in.
</span>
<span>
<span data-i18n="Saved locally. Not exported.">
Saved locally. Not exported.
</span>
</span>
</small>
</div>
<div class="characterAttachmentsList attachmentsList"></div>
<hr>
</div>
<div class="justifyLeft chatAttachmentsBlock marginBot10">
<h3 class="chatAttachmentsTitle margin0 title_restorable">
<span data-i18n="Chat Attachments">
Chat Attachments
</span>
<div class="openActionModalButton menu_button menu_button_icon">
<i class="fa-solid fa-plus"></i>
<span data-i18n="Add">Add</span>
</div>
</h3>
<div class="flex-container flexFlowColumn">
<strong><small class="chatAttachmentsName"></small></strong>
<small data-i18n="These files are available to all characters in the current chat.">
These files are available to all characters in the current chat.
</small>
</div>
<div class="chatAttachmentsList attachmentsList"></div>
</div>
<div class="attachmentListItemTemplate template_element">
<div class="attachmentListItem flex-container alignItemsCenter flexGap10">
<div class="attachmentFileIcon fa-solid fa-file-alt"></div>
<div class="attachmentListItemName flex1"></div>
<small class="attachmentListItemCreated"></small>
<small class="attachmentListItemSize"></small>
<div class="viewAttachmentButton right_menu_button fa-solid fa-magnifying-glass" title="View attachment content"></div>
<div class="editAttachmentButton right_menu_button fa-solid fa-pencil" title="Edit attachment"></div>
<div class="deleteAttachmentButton right_menu_button fa-solid fa-trash" title="Delete attachment"></div>
</div>
</div>
<div class="actionButtonTemplate">
<div class="actionButton list-group-item flex-container flexGap5" title="">
<i class="actionButtonIcon"></i>
<span class="actionButtonText"></span>
</div>
</div>
<div class="actionButtonsModal popper-modal options-content list-group"></div>
</div>

View File

@ -0,0 +1,11 @@
{
"display_name": "Chat Attachments",
"loading_order": 3,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "Cohee1207",
"version": "1.0.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

View File

@ -0,0 +1,10 @@
<div class="flex-container flexFlowColumn height100p">
<label for="notepadFileName">
File Name
</label>
<input type="text" class="text_pole" id="notepadFileName" name="notepadFileName" value="" />
<labels>
File Content
</label>
<textarea id="notepadFileContent" name="notepadFileContent" class="text_pole textarea_compact monospace flex1" placeholder="Enter your notes here."></textarea>
</div>

View File

@ -0,0 +1,29 @@
.attachmentsList:empty {
width: 100%;
height: 100%;
}
.attachmentsList:empty::before {
display: flex;
align-items: center;
justify-content: center;
content: "No data";
font-weight: bolder;
width: 100%;
height: 100%;
opacity: 0.8;
min-height: 3rem;
}
.attachmentListItem {
padding: 10px;
}
.attachmentListItemSize {
min-width: 4em;
text-align: right;
}
.attachmentListItemCreated {
text-align: right;
}

View File

@ -0,0 +1,3 @@
<div data-i18n="Enter web URLs to scrape (one per line):">
Enter web URLs to scrape (one per line):
</div>

View File

@ -0,0 +1,20 @@
<div>
<strong data-i18n="Enter a video URL to download its transcript.">
Enter a video URL or ID to download its transcript.
</strong>
<div data-i18n="Examples:" class="m-t-1">
Examples:
</div>
<ul class="justifyLeft">
<li>https://www.youtube.com/watch?v=jV1vkHv4zq8</li>
<li>https://youtu.be/nlLhw1mtCFA</li>
<li>TDpxx5UqrVU</li>
</ul>
<label>
Language code (optional 2-letter ISO code):
</label>
<input type="text" class="text_pole" name="youtubeLanguageCode" placeholder="e.g. en">
<label>
Video ID:
</label>
</div>

View File

@ -310,14 +310,8 @@ jQuery(function () {
<div class="fa-solid fa-image extensionsMenuExtensionButton"></div>
Generate Caption
</div>`);
const attachFileButton = $(`
<div id="attachFile" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-paperclip extensionsMenuExtensionButton"></div>
Attach a File
</div>`);
$('#extensionsMenu').prepend(sendButton);
$('#extensionsMenu').prepend(attachFileButton);
$(sendButton).on('click', () => {
const hasCaptionModule =
(modules.includes('caption') && extension_settings.caption.source === 'extras') ||

View File

@ -507,6 +507,10 @@ async function loadTalkingHead() {
},
body: JSON.stringify(emotionsSettings),
});
if (!apiResult.ok) {
throw new Error(apiResult.statusText);
}
}
catch (error) {
// it's ok if not supported
@ -539,6 +543,10 @@ async function loadTalkingHead() {
},
body: JSON.stringify(animatorSettings),
});
if (!apiResult.ok) {
throw new Error(apiResult.statusText);
}
}
catch (error) {
// it's ok if not supported

View File

@ -1,4 +1,4 @@
import { getStringHash, debounce, waitUntilCondition, extractAllWords, delay } from '../../utils.js';
import { getStringHash, debounce, waitUntilCondition, extractAllWords } from '../../utils.js';
import { getContext, getApiUrl, extension_settings, doExtrasFetch, modules, renderExtensionTemplateAsync } from '../../extensions.js';
import {
activateSendButtons,

View File

@ -23,7 +23,6 @@ export async function getMultimodalCaption(base64Img, prompt) {
// OpenRouter has a payload limit of ~2MB. Google is 4MB, but we love democracy.
// Ooba requires all images to be JPEGs. Koboldcpp just asked nicely.
const isGoogle = extension_settings.caption.multimodal_api === 'google';
const isClaude = extension_settings.caption.multimodal_api === 'anthropic';
const isOllama = extension_settings.caption.multimodal_api === 'ollama';
const isLlamaCpp = extension_settings.caption.multimodal_api === 'llamacpp';
const isCustom = extension_settings.caption.multimodal_api === 'custom';

View File

@ -433,8 +433,8 @@ class AllTalkTtsProvider {
updateLanguageDropdown() {
const languageSelect = document.getElementById('language_options');
if (languageSelect) {
// Ensure default language is set
this.settings.language = this.settings.language;
// Ensure default language is set (??? whatever that means)
// this.settings.language = this.settings.language;
languageSelect.innerHTML = '';
for (let language in this.languageLabels) {

View File

@ -2,6 +2,7 @@ import {
eventSource,
event_types,
extension_prompt_types,
extension_prompt_roles,
getCurrentChatId,
getRequestHeaders,
is_send_press,
@ -20,11 +21,13 @@ import {
} from '../../extensions.js';
import { collapseNewlines } from '../../power-user.js';
import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';
import { getDataBankAttachments, getFileAttachment } from '../../chats.js';
import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive } from '../../utils.js';
const MODULE_NAME = 'vectors';
export const EXTENSION_PROMPT_TAG = '3_vectors';
export const EXTENSION_PROMPT_TAG_DB = '4_vectors_data_bank';
const settings = {
// For both
@ -32,6 +35,7 @@ const settings = {
include_wi: false,
togetherai_model: 'togethercomputer/m2-bert-80M-32k-retrieval',
openai_model: 'text-embedding-ada-002',
cohere_model: 'embed-english-v3.0',
summarize: false,
summarize_sent: false,
summary_source: 'main',
@ -39,7 +43,7 @@ const settings = {
// For chats
enabled_chats: false,
template: 'Past events: {{text}}',
template: 'Past events:\n{{text}}',
depth: 2,
position: extension_prompt_types.IN_PROMPT,
protect: 5,
@ -49,13 +53,32 @@ const settings = {
// For files
enabled_files: false,
translate_files: false,
size_threshold: 10,
chunk_size: 5000,
chunk_count: 2,
// For Data Bank
size_threshold_db: 5,
chunk_size_db: 2500,
chunk_count_db: 5,
file_template_db: 'Related information:\n{{text}}',
file_position_db: extension_prompt_types.IN_PROMPT,
file_depth_db: 4,
file_depth_role_db: extension_prompt_roles.SYSTEM,
};
const moduleWorker = new ModuleWorkerWrapper(synchronizeChat);
/**
* Gets the Collection ID for a file embedded in the chat.
* @param {string} fileUrl URL of the file
* @returns {string} Collection ID
*/
function getFileCollectionId(fileUrl) {
return `file_${getStringHash(fileUrl)}`;
}
async function onVectorizeAllClick() {
try {
if (!settings.enabled_chats) {
@ -292,6 +315,34 @@ async function processFiles(chat) {
return;
}
const dataBank = getDataBankAttachments();
const dataBankCollectionIds = [];
for (const file of dataBank) {
const collectionId = getFileCollectionId(file.url);
const hashesInCollection = await getSavedHashes(collectionId);
dataBankCollectionIds.push(collectionId);
// File is already in the collection
if (hashesInCollection.length) {
continue;
}
// Download and process the file
file.text = await getFileAttachment(file.url);
console.log(`Vectors: Retrieved file ${file.name} from Data Bank`);
// Convert kilobytes to string length
const thresholdLength = settings.size_threshold_db * 1024;
// Use chunk size from settings if file is larger than threshold
const chunkSize = file.size > thresholdLength ? settings.chunk_size_db : -1;
await vectorizeFile(file.text, file.name, collectionId, chunkSize);
}
if (dataBankCollectionIds.length) {
const queryText = await getQueryText(chat);
await injectDataBankChunks(queryText, dataBankCollectionIds);
}
for (const message of chat) {
// Message has no file
if (!message?.extra?.file) {
@ -300,8 +351,7 @@ async function processFiles(chat) {
// Trim file inserted by the script
const fileText = String(message.mes)
.substring(0, message.extra.fileLength).trim()
.replace(/^```/, '').replace(/```$/, '').trim();
.substring(0, message.extra.fileLength).trim();
// Convert kilobytes to string length
const thresholdLength = settings.size_threshold * 1024;
@ -314,25 +364,55 @@ async function processFiles(chat) {
message.mes = message.mes.substring(message.extra.fileLength);
const fileName = message.extra.file.name;
const collectionId = `file_${getStringHash(fileName)}`;
const fileUrl = message.extra.file.url;
const collectionId = getFileCollectionId(fileUrl);
const hashesInCollection = await getSavedHashes(collectionId);
// File is already in the collection
if (!hashesInCollection.length) {
await vectorizeFile(fileText, fileName, collectionId);
await vectorizeFile(fileText, fileName, collectionId, settings.chunk_size);
}
const queryText = await getQueryText(chat);
const fileChunks = await retrieveFileChunks(queryText, collectionId);
// Wrap it back in a code block
message.mes = `\`\`\`\n${fileChunks}\n\`\`\`\n\n${message.mes}`;
message.mes = `${fileChunks}\n\n${message.mes}`;
}
} catch (error) {
console.error('Vectors: Failed to retrieve files', error);
}
}
/**
* Inserts file chunks from the Data Bank into the prompt.
* @param {string} queryText Text to query
* @param {string[]} collectionIds File collection IDs
* @returns {Promise<void>}
*/
async function injectDataBankChunks(queryText, collectionIds) {
try {
const queryResults = await queryMultipleCollections(collectionIds, queryText, settings.chunk_count_db);
console.debug(`Vectors: Retrieved ${collectionIds.length} Data Bank collections`, queryResults);
let textResult = '';
for (const collectionId in queryResults) {
console.debug(`Vectors: Processing Data Bank collection ${collectionId}`, queryResults[collectionId]);
const metadata = queryResults[collectionId].metadata?.filter(x => x.text)?.sort((a, b) => a.index - b.index)?.map(x => x.text)?.filter(onlyUnique) || [];
textResult += metadata.join('\n') + '\n\n';
}
if (!textResult) {
console.debug('Vectors: No Data Bank chunks found');
return;
}
const insertedText = substituteParams(settings.file_template_db.replace(/{{text}}/i, textResult));
setExtensionPrompt(EXTENSION_PROMPT_TAG_DB, insertedText, settings.file_position_db, settings.file_depth_db, settings.include_wi, settings.file_depth_role_db);
} catch (error) {
console.error('Vectors: Failed to insert Data Bank chunks', error);
}
}
/**
* Retrieves file chunks from the vector index and inserts them into the chat.
* @param {string} queryText Text to query
@ -354,16 +434,24 @@ async function retrieveFileChunks(queryText, collectionId) {
* @param {string} fileText File text
* @param {string} fileName File name
* @param {string} collectionId File collection ID
* @param {number} chunkSize Chunk size
*/
async function vectorizeFile(fileText, fileName, collectionId) {
async function vectorizeFile(fileText, fileName, collectionId, chunkSize) {
try {
toastr.info('Vectorization may take some time, please wait...', `Ingesting file ${fileName}`);
const chunks = splitRecursive(fileText, settings.chunk_size);
if (settings.translate_files && typeof window['translate'] === 'function') {
console.log(`Vectors: Translating file ${fileName} to English...`);
const translatedText = await window['translate'](fileText, 'en');
fileText = translatedText;
}
const toast = toastr.info('Vectorization may take some time, please wait...', `Ingesting file ${fileName}`);
const chunks = splitRecursive(fileText, chunkSize);
console.debug(`Vectors: Split file ${fileName} into ${chunks.length} chunks`, chunks);
const items = chunks.map((chunk, index) => ({ hash: getStringHash(chunk), text: chunk, index: index }));
await insertVectorItems(collectionId, items);
toastr.clear(toast);
console.log(`Vectors: Inserted ${chunks.length} vector items for file ${fileName} into ${collectionId}`);
} catch (error) {
console.error('Vectors: Failed to vectorize file', error);
@ -377,7 +465,8 @@ async function vectorizeFile(fileText, fileName, collectionId) {
async function rearrangeChat(chat) {
try {
// Clear the extension prompt
setExtensionPrompt(EXTENSION_PROMPT_TAG, '', extension_prompt_types.IN_PROMPT, 0, settings.include_wi);
setExtensionPrompt(EXTENSION_PROMPT_TAG, '', settings.position, settings.depth, settings.include_wi);
setExtensionPrompt(EXTENSION_PROMPT_TAG_DB, '', settings.file_position_db, settings.file_depth_db, settings.include_wi, settings.file_depth_role_db);
if (settings.enabled_files) {
await processFiles(chat);
@ -526,6 +615,9 @@ function getVectorHeaders() {
case 'openai':
addOpenAiHeaders(headers);
break;
case 'cohere':
addCohereHeaders(headers);
break;
default:
break;
}
@ -564,6 +656,16 @@ function addOpenAiHeaders(headers) {
});
}
/**
* Add headers for the Cohere API source.
* @param {object} headers Header object
*/
function addCohereHeaders(headers) {
Object.assign(headers, {
'X-Cohere-Model': extension_settings.vectors.cohere_model,
});
}
/**
* Inserts vector items into a collection
* @param {string} collectionId - The collection to insert into
@ -575,7 +677,8 @@ async function insertVectorItems(collectionId, items) {
settings.source === 'palm' && !secret_state[SECRET_KEYS.MAKERSUITE] ||
settings.source === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] ||
settings.source === 'togetherai' && !secret_state[SECRET_KEYS.TOGETHERAI] ||
settings.source === 'nomicai' && !secret_state[SECRET_KEYS.NOMICAI]) {
settings.source === 'nomicai' && !secret_state[SECRET_KEYS.NOMICAI] ||
settings.source === 'cohere' && !secret_state[SECRET_KEYS.COHERE]) {
throw new Error('Vectors: API key missing', { cause: 'api_key_missing' });
}
@ -649,6 +752,65 @@ async function queryCollection(collectionId, searchText, topK) {
return await response.json();
}
/**
* Queries multiple collections for a given text.
* @param {string[]} collectionIds - Collection IDs to query
* @param {string} searchText - Text to query
* @param {number} topK - Number of results to return
* @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - Results mapped to collection IDs
*/
async function queryMultipleCollections(collectionIds, searchText, topK) {
const headers = getVectorHeaders();
const response = await fetch('/api/vector/query-multi', {
method: 'POST',
headers: headers,
body: JSON.stringify({
collectionIds: collectionIds,
searchText: searchText,
topK: topK,
source: settings.source,
}),
});
if (!response.ok) {
throw new Error('Failed to query multiple collections');
}
return await response.json();
}
/**
* Purges the vector index for a file.
* @param {string} fileUrl File URL to purge
*/
async function purgeFileVectorIndex(fileUrl) {
try {
if (!settings.enabled_files) {
return;
}
console.log(`Vectors: Purging file vector index for ${fileUrl}`);
const collectionId = getFileCollectionId(fileUrl);
const response = await fetch('/api/vector/purge', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
collectionId: collectionId,
}),
});
if (!response.ok) {
throw new Error(`Could not delete vector index for collection ${collectionId}`);
}
console.log(`Vectors: Purged vector index for collection ${collectionId}`);
} catch (error) {
console.error('Vectors: Failed to purge file', error);
}
}
/**
* Purges the vector index for a collection.
* @param {string} collectionId Collection ID to purge
@ -685,6 +847,7 @@ function toggleSettings() {
$('#vectors_chats_settings').toggle(!!settings.enabled_chats);
$('#together_vectorsModel').toggle(settings.source === 'togetherai');
$('#openai_vectorsModel').toggle(settings.source === 'openai');
$('#cohere_vectorsModel').toggle(settings.source === 'cohere');
$('#nomicai_apiKey').toggle(settings.source === 'nomicai');
}
@ -728,6 +891,49 @@ async function onViewStatsClick() {
}
async function onVectorizeAllFilesClick() {
try {
const dataBank = getDataBankAttachments();
const chatAttachments = getContext().chat.filter(x => x.extra?.file).map(x => x.extra.file);
const allFiles = [...dataBank, ...chatAttachments];
for (const file of allFiles) {
const text = await getFileAttachment(file.url);
const collectionId = getFileCollectionId(file.url);
const hashes = await getSavedHashes(collectionId);
if (hashes.length) {
console.log(`Vectors: File ${file.name} is already vectorized`);
continue;
}
await vectorizeFile(text, file.name, collectionId, settings.chunk_size);
}
toastr.success('All files vectorized', 'Vectorization successful');
} catch (error) {
console.error('Vectors: Failed to vectorize all files', error);
toastr.error('Failed to vectorize all files', 'Vectorization failed');
}
}
async function onPurgeFilesClick() {
try {
const dataBank = getDataBankAttachments();
const chatAttachments = getContext().chat.filter(x => x.extra?.file).map(x => x.extra.file);
const allFiles = [...dataBank, ...chatAttachments];
for (const file of allFiles) {
await purgeFileVectorIndex(file.url);
}
toastr.success('All files purged', 'Purge successful');
} catch (error) {
console.error('Vectors: Failed to purge all files', error);
toastr.error('Failed to purge all files', 'Purge failed');
}
}
jQuery(async () => {
if (!extension_settings.vectors) {
extension_settings.vectors = settings;
@ -782,6 +988,12 @@ jQuery(async () => {
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_cohere_model').val(settings.cohere_model).on('change', () => {
$('#vectors_modelWarning').show();
settings.cohere_model = String($('#vectors_cohere_model').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_template').val(settings.template).on('input', () => {
settings.template = String($('#vectors_template').val());
Object.assign(extension_settings.vectors, settings);
@ -816,6 +1028,8 @@ jQuery(async () => {
$('#vectors_vectorize_all').on('click', onVectorizeAllClick);
$('#vectors_purge').on('click', onPurgeClick);
$('#vectors_view_stats').on('click', onViewStatsClick);
$('#vectors_files_vectorize_all').on('click', onVectorizeAllFilesClick);
$('#vectors_files_purge').on('click', onPurgeFilesClick);
$('#vectors_size_threshold').val(settings.size_threshold).on('input', () => {
settings.size_threshold = Number($('#vectors_size_threshold').val());
@ -871,6 +1085,55 @@ jQuery(async () => {
saveSettingsDebounced();
});
$('#vectors_size_threshold_db').val(settings.size_threshold_db).on('input', () => {
settings.size_threshold_db = Number($('#vectors_size_threshold_db').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_chunk_size_db').val(settings.chunk_size_db).on('input', () => {
settings.chunk_size_db = Number($('#vectors_chunk_size_db').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_chunk_count_db').val(settings.chunk_count_db).on('input', () => {
settings.chunk_count_db = Number($('#vectors_chunk_count_db').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_file_template_db').val(settings.file_template_db).on('input', () => {
settings.file_template_db = String($('#vectors_file_template_db').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$(`input[name="vectors_file_position_db"][value="${settings.file_position_db}"]`).prop('checked', true);
$('input[name="vectors_file_position_db"]').on('change', () => {
settings.file_position_db = Number($('input[name="vectors_file_position_db"]:checked').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_file_depth_db').val(settings.file_depth_db).on('input', () => {
settings.file_depth_db = Number($('#vectors_file_depth_db').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_file_depth_role_db').val(settings.file_depth_role_db).on('input', () => {
settings.file_depth_role_db = Number($('#vectors_file_depth_role_db').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_translate_files').prop('checked', settings.translate_files).on('input', () => {
settings.translate_files = !!$('#vectors_translate_files').prop('checked');
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
const validSecret = !!secret_state[SECRET_KEYS.NOMICAI];
const placeholder = validSecret ? '✔️ Key saved' : '❌ Missing key';
$('#api_key_nomicai').attr('placeholder', placeholder);
@ -883,4 +1146,5 @@ jQuery(async () => {
eventSource.on(event_types.MESSAGE_SWIPED, onChatEvent);
eventSource.on(event_types.CHAT_DELETED, purgeVectorIndex);
eventSource.on(event_types.GROUP_CHAT_DELETED, purgeVectorIndex);
eventSource.on(event_types.FILE_ATTACHMENT_DELETED, purgeFileVectorIndex);
});

View File

@ -10,13 +10,14 @@
Vectorization Source
</label>
<select id="vectors_source" class="text_pole">
<option value="transformers">Local (Transformers)</option>
<option value="cohere">Cohere</option>
<option value="extras">Extras</option>
<option value="openai">OpenAI</option>
<option value="palm">Google MakerSuite (PaLM)</option>
<option value="transformers">Local (Transformers)</option>
<option value="mistral">MistralAI</option>
<option value="togetherai">TogetherAI</option>
<option value="nomicai">NomicAI</option>
<option value="openai">OpenAI</option>
<option value="togetherai">TogetherAI</option>
</select>
</div>
<div class="flex-container flexFlowColumn" id="openai_vectorsModel">
@ -29,6 +30,20 @@
<option value="text-embedding-3-large">text-embedding-3-large</option>
</select>
</div>
<div class="flex-container flexFlowColumn" id="cohere_vectorsModel">
<label for="vectors_cohere_model">
Vectorization Model
</label>
<select id="vectors_cohere_model" class="text_pole">
<option value="embed-english-v3.0">embed-english-v3.0</option>
<option value="embed-multilingual-v3.0">embed-multilingual-v3.0</option>
<option value="embed-english-light-v3.0">embed-english-light-v3.0</option>
<option value="embed-multilingual-light-v3.0">embed-multilingual-light-v3.0</option>
<option value="embed-english-v2.0">embed-english-v2.0</option>
<option value="embed-english-light-v2.0">embed-english-light-v2.0</option>
<option value="embed-multilingual-v2.0">embed-multilingual-v2.0</option>
</select>
</div>
<div class="flex-container flexFlowColumn" id="together_vectorsModel">
<label for="vectors_togetherai_model">
Vectorization Model
@ -91,7 +106,17 @@
Enabled for files
</label>
<div id="vectors_files_settings">
<div id="vectors_files_settings" class="marginTopBot5">
<label class="checkbox_label" for="vectors_translate_files" title="This can help with retrieval accuracy if using embedding models that are trained on English data. Uses the selected API from Chat Translation extension settings.">
<input id="vectors_translate_files" type="checkbox" class="checkbox">
<span data-i18n="Translate files into English before processing">
Translate files into English before processing
</span>
<i class="fa-solid fa-flask" title="Experimental feature"></i>
</label>
<div class="flex justifyCenter" title="These settings apply to files attached directly to messages.">
<span>Message attachments</span>
</div>
<div class="flex-container">
<div class="flex1" title="Only files past this size will be vectorized.">
<label for="vectors_size_threshold">
@ -112,6 +137,66 @@
<input id="vectors_chunk_count" type="number" class="text_pole widthUnset" min="1" max="99999" />
</div>
</div>
<div class="flex justifyCenter" title="These settings apply to files stored in the Data Bank.">
<span>Data Bank files</span>
</div>
<div class="flex-container">
<div class="flex1" title="Only files past this size will be vectorized.">
<label for="vectors_size_threshold_db">
<small>Size threshold (KB)</small>
</label>
<input id="vectors_size_threshold_db" type="number" class="text_pole widthUnset" min="1" max="99999" />
</div>
<div class="flex1" title="Chunk size for file splitting.">
<label for="vectors_chunk_size_db">
<small>Chunk size (chars)</small>
</label>
<input id="vectors_chunk_size_db" type="number" class="text_pole widthUnset" min="1" max="99999" />
</div>
<div class="flex1" title="How many chunks to retrieve when querying.">
<label for="vectors_chunk_count_db">
<small>Retrieve chunks</small>
</label>
<input id="vectors_chunk_count_db" type="number" class="text_pole widthUnset" min="1" max="99999" />
</div>
</div>
<div class="flex-container flexFlowColumn">
<label for="vectors_file_template_db">
<span>Injection Template</span>
</label>
<textarea id="vectors_file_template_db" class="margin0 text_pole textarea_compact" rows="3" placeholder="Use &lcub;&lcub;text&rcub;&rcub; macro to specify the position of retrieved text."></textarea>
<label for="vectors_file_position_db">Injection Position</label>
<div class="radio_group">
<label>
<input type="radio" name="vectors_file_position_db" value="2" />
<span>Before Main Prompt / Story String</span>
</label>
<!--Keep these as 0 and 1 to interface with the setExtensionPrompt function-->
<label>
<input type="radio" name="vectors_file_position_db" value="0" />
<span>After Main Prompt / Story String</span>
</label>
<label for="vectors_file_depth_db" title="How many messages before the current end of the chat." data-i18n="[title]How many messages before the current end of the chat.">
<input type="radio" name="vectors_file_position_db" value="1" />
<span>In-chat @ Depth</span>
<input id="vectors_file_depth_db" class="text_pole widthUnset" type="number" min="0" max="999" />
<span>as</span>
<select id="vectors_file_depth_role_db" class="text_pole widthNatural">
<option value="0">System</option>
<option value="1">User</option>
<option value="2">Assistant</option>
</select>
</label>
</div>
</div>
<div class="flex-container">
<div id="vectors_files_vectorize_all" class="menu_button menu_button_icon" title="Vectorize all files in the Data Bank and current chat.">
Vectorize All
</div>
<div id="vectors_files_purge" class="menu_button menu_button_icon" title="Purge all file vectors in the Data Bank and current chat.">
Purge Vectors
</div>
</div>
</div>
<hr>
@ -129,9 +214,9 @@
<div id="vectors_chats_settings">
<div id="vectors_advanced_settings">
<label for="vectors_template">
Insertion Template
Injection Template
</label>
<textarea id="vectors_template" class="text_pole textarea_compact" rows="3" placeholder="Use {{text}} macro to specify the position of retrieved text."></textarea>
<textarea id="vectors_template" class="text_pole textarea_compact" rows="3" placeholder="Use &lcub;&lcub;text&rcub;&rcub; macro to specify the position of retrieved text."></textarea>
<label for="vectors_position">Injection Position</label>
<div class="radio_group">
<label>

View File

@ -5,21 +5,18 @@ export function showLoader() {
const loader = $('<div></div>').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x');
container.append(loader);
$('body').append(container);
}
export function hideLoader() {
export async function hideLoader() {
//Sets up a 2-step animation. Spinner blurs/fades out, and then the loader shadow does the same.
$('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
//console.log('FADING BLUR SCREEN')
$(`#${ELEMENT_ID}`)
//only fade out the spinner and replace with login screen
.animate({ opacity: 0 }, 300, function () {
//console.log('REMOVING LOADER')
$(`#${ELEMENT_ID}`).remove();
});
});
//console.log('BLURRING SPINNER')
$('#load-spinner')
.css({
'filter': 'blur(15px)',

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

@ -0,0 +1,276 @@
/**
* 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.
* Preserves the query string.
*/
function redirectToHome() {
window.location.href = '/' + window.location.search;
}
/**
* 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>').addClass('userName').text(user.name));
userBlock.append($('<small></small>').addClass('userHandle').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

@ -129,8 +129,8 @@ function addKeyboardProps(element) {
* selected token highlighted. If no token is selected, the subview is hidden.
*/
function renderTopLogprobs() {
$('#logprobs_top_logprobs_hint').hide();
const view = $('.logprobs_candidate_list');
const hint = $('#logprobs_top_logprobs_hint').hide();
view.empty();
if (!state.selectedTokenLogprobs) {

View File

@ -31,7 +31,7 @@ import {
system_message_types,
this_chid,
} from '../script.js';
import { groups, selected_group } from './group-chats.js';
import { selected_group } from './group-chats.js';
import { registerSlashCommand } from './slash-commands.js';
import {
@ -1009,6 +1009,15 @@ async function populateChatCompletion(prompts, chatCompletion, { bias, quietProm
}
}
// Vectors Data Bank
if (prompts.has('vectorsDataBank')) {
const vectorsDataBank = prompts.get('vectorsDataBank');
if (vectorsDataBank.position) {
chatCompletion.insert(Message.fromPrompt(vectorsDataBank), 'main', vectorsDataBank.position);
}
}
// Smart Context (ChromaDB)
if (prompts.has('smartContext')) {
const smartContext = prompts.get('smartContext');
@ -1100,6 +1109,14 @@ function preparePromptsForChatCompletion({ Scenario, charPersonality, name2, wor
position: getPromptPosition(vectorsMemory.position),
});
const vectorsDataBank = extensionPrompts['4_vectors_data_bank'];
if (vectorsDataBank && vectorsDataBank.value) systemPrompts.push({
role: getPromptRole(vectorsDataBank.role),
content: vectorsDataBank.value,
identifier: 'vectorsDataBank',
position: getPromptPosition(vectorsDataBank.position),
});
// Smart Context (ChromaDB)
const smartContext = extensionPrompts['chromadb'];
if (smartContext && smartContext.value) systemPrompts.push({
@ -1607,6 +1624,11 @@ async function sendAltScaleRequest(messages, logit_bias, signal, type) {
signal: signal,
});
if (!response.ok) {
tryParseStreamingError(response, await response.text());
throw new Error('Scale response does not indicate success.');
}
const data = await response.json();
return data.output;
}
@ -3603,6 +3625,7 @@ async function onModelChange() {
}
oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
$('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
$('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
}
if (oai_settings.chat_completion_source == chat_completion_sources.MAKERSUITE) {
@ -3730,6 +3753,7 @@ async function onModelChange() {
}
oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
$('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
$('#temp_openai').attr('max', claude_max_temp).val(oai_settings.temp_openai).trigger('input');
}
if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) {
@ -3790,6 +3814,7 @@ async function onModelChange() {
$('#openai_max_context').attr('max', unlocked_max);
oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
$('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
$('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
}
$('#openai_max_context_counter').attr('max', Number($('#openai_max_context').attr('max')));

View File

@ -255,7 +255,7 @@ let power_user = {
compact_input_area: true,
auto_connect: false,
auto_load_chat: false,
forbid_external_images: false,
forbid_external_media: true,
external_media_allowed_overrides: [],
external_media_forbidden_overrides: [],
};
@ -819,7 +819,7 @@ async function CreateZenSliders(elmnt) {
isManualInput = true;
//allow enter to trigger slider update
if (e.key === 'Enter') {
e.preventDefault;
e.preventDefault();
handle.trigger('blur');
}
})
@ -1584,7 +1584,7 @@ function loadPowerUserSettings(settings, data) {
$('#reduced_motion').prop('checked', power_user.reduced_motion);
$('#auto-connect-checkbox').prop('checked', power_user.auto_connect);
$('#auto-load-chat-checkbox').prop('checked', power_user.auto_load_chat);
$('#forbid_external_images').prop('checked', power_user.forbid_external_images);
$('#forbid_external_media').prop('checked', power_user.forbid_external_media);
for (const theme of themes) {
const option = document.createElement('option');
@ -3527,8 +3527,8 @@ $(document).ready(() => {
saveSettingsDebounced();
});
$('#forbid_external_images').on('input', function () {
power_user.forbid_external_images = !!$(this).prop('checked');
$('#forbid_external_media').on('input', function () {
power_user.forbid_external_media = !!$(this).prop('checked');
saveSettingsDebounced();
reloadCurrentChat();
});

416
public/scripts/scrapers.js Normal file
View File

@ -0,0 +1,416 @@
import { getRequestHeaders } from '../script.js';
import { renderExtensionTemplateAsync } from './extensions.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
import { isValidUrl } from './utils.js';
/**
* @typedef {Object} Scraper
* @property {string} id
* @property {string} name
* @property {string} description
* @property {string} iconClass
* @property {() => Promise<boolean>} isAvailable
* @property {() => Promise<File[]>} scrape
*/
/**
* @typedef {Object} ScraperInfo
* @property {string} id
* @property {string} name
* @property {string} description
* @property {string} iconClass
*/
export class ScraperManager {
/**
* @type {Scraper[]}
*/
static #scrapers = [];
/**
* Register a scraper to be used by the Data Bank.
* @param {Scraper} scraper Instance of a scraper to register
*/
static registerDataBankScraper(scraper) {
if (ScraperManager.#scrapers.some(s => s.id === scraper.id)) {
console.warn(`Scraper with ID ${scraper.id} already registered`);
return;
}
ScraperManager.#scrapers.push(scraper);
}
/**
* Gets a list of scrapers available for the Data Bank.
* @returns {ScraperInfo[]} List of scrapers available for the Data Bank
*/
static getDataBankScrapers() {
return ScraperManager.#scrapers.map(s => ({ id: s.id, name: s.name, description: s.description, iconClass: s.iconClass }));
}
/**
* Run a scraper to scrape data into the Data Bank.
* @param {string} scraperId ID of the scraper to run
* @returns {Promise<File[]>} List of files scraped by the scraper
*/
static runDataBankScraper(scraperId) {
const scraper = ScraperManager.#scrapers.find(s => s.id === scraperId);
if (!scraper) {
console.warn(`Scraper with ID ${scraperId} not found`);
return;
}
return scraper.scrape();
}
/**
* Check if a scraper is available.
* @param {string} scraperId ID of the scraper to check
* @returns {Promise<boolean>} Whether the scraper is available
*/
static isScraperAvailable(scraperId) {
const scraper = ScraperManager.#scrapers.find(s => s.id === scraperId);
if (!scraper) {
console.warn(`Scraper with ID ${scraperId} not found`);
return;
}
return scraper.isAvailable();
}
}
/**
* Create a text file from a string.
* @implements {Scraper}
*/
class Notepad {
constructor() {
this.id = 'text';
this.name = 'Notepad';
this.description = 'Create a text file from scratch.';
this.iconClass = 'fa-solid fa-note-sticky';
}
/**
* Check if the scraper is available.
* @returns {Promise<boolean>}
*/
async isAvailable() {
return true;
}
/**
* Create a text file from a string.
* @returns {Promise<File[]>} File attachments scraped from the text
*/
async scrape() {
const template = $(await renderExtensionTemplateAsync('attachments', 'notepad', {}));
let fileName = `Untitled - ${new Date().toLocaleString()}`;
let text = '';
template.find('input[name="notepadFileName"]').val(fileName).on('input', function () {
fileName = String($(this).val()).trim();
});
template.find('textarea[name="notepadFileContent"]').on('input', function () {
text = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: true, large: true, okButton: 'Save', cancelButton: 'Cancel' });
if (!result || text === '') {
return;
}
const file = new File([text], `Notepad - ${fileName}.txt`, { type: 'text/plain' });
return [file];
}
}
/**
* Scrape data from a webpage.
* @implements {Scraper}
*/
class WebScraper {
constructor() {
this.id = 'web';
this.name = 'Web';
this.description = 'Download a page from the web.';
this.iconClass = 'fa-solid fa-globe';
}
/**
* Check if the scraper is available.
* @returns {Promise<boolean>}
*/
async isAvailable() {
return true;
}
/**
* Parse the title of an HTML file from a Blob.
* @param {Blob} blob Blob of the HTML file
* @returns {Promise<string>} Title of the HTML file
*/
async getTitleFromHtmlBlob(blob) {
const text = await blob.text();
const titleMatch = text.match(/<title>(.*?)<\/title>/i);
return titleMatch ? titleMatch[1] : '';
}
/**
* Scrape file attachments from a webpage.
* @returns {Promise<File[]>} File attachments scraped from the webpage
*/
async scrape() {
const template = $(await renderExtensionTemplateAsync('attachments', 'web-scrape', {}));
const linksString = await callGenericPopup(template, POPUP_TYPE.INPUT, '', { wide: false, large: false, okButton: 'Scrape', cancelButton: 'Cancel', rows: 4 });
if (!linksString) {
return;
}
const links = String(linksString).split('\n').map(l => l.trim()).filter(l => l).filter(l => isValidUrl(l));
if (links.length === 0) {
toastr.error('Invalid URL');
return;
}
const toast = toastr.info('Working, please wait...');
const files = [];
for (const link of links) {
const result = await fetch('/api/serpapi/visit', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ url: link }),
});
const blob = await result.blob();
const domain = new URL(link).hostname;
const timestamp = Date.now();
const title = await this.getTitleFromHtmlBlob(blob) || 'webpage';
const file = new File([blob], `${title} - ${domain} - ${timestamp}.html`, { type: 'text/html' });
files.push(file);
}
toastr.clear(toast);
return files;
}
}
/**
* Scrape data from a file selection.
* @implements {Scraper}
*/
class FileScraper {
constructor() {
this.id = 'file';
this.name = 'File';
this.description = 'Upload a file from your computer.';
this.iconClass = 'fa-solid fa-upload';
}
/**
* Check if the scraper is available.
* @returns {Promise<boolean>}
*/
async isAvailable() {
return true;
}
/**
* Scrape file attachments from a file.
* @returns {Promise<File[]>} File attachments scraped from the files
*/
async scrape() {
return new Promise(resolve => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '*/*';
fileInput.multiple = true;
fileInput.onchange = () => resolve(Array.from(fileInput.files));
fileInput.click();
});
}
}
/**
* Scrape data from a Fandom wiki.
* @implements {Scraper}
*/
class FandomScraper {
constructor() {
this.id = 'fandom';
this.name = 'Fandom';
this.description = 'Download a page from the Fandom wiki.';
this.iconClass = 'fa-solid fa-fire';
}
/**
* Check if the scraper is available.
* @returns {Promise<boolean>}
*/
async isAvailable() {
try {
const result = await fetch('/api/plugins/fandom/probe', {
method: 'POST',
headers: getRequestHeaders(),
});
return result.ok;
} catch (error) {
console.debug('Could not probe Fandom plugin', error);
return false;
}
}
/**
* Get the ID of a fandom from a URL or name.
* @param {string} fandom URL or name of the fandom
* @returns {string} ID of the fandom
*/
getFandomId(fandom) {
try {
const url = new URL(fandom);
return url.hostname.split('.')[0] || fandom;
} catch {
return fandom;
}
}
async scrape() {
let fandom = '';
let filter = '';
let output = 'single';
const template = $(await renderExtensionTemplateAsync('attachments', 'fandom-scrape', {}));
template.find('input[name="fandomScrapeInput"]').on('input', function () {
fandom = String($(this).val()).trim();
});
template.find('input[name="fandomScrapeFilter"]').on('input', function () {
filter = String($(this).val());
});
template.find('input[name="fandomScrapeOutput"]').on('input', function () {
output = String($(this).val());
});
const confirm = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Scrape', cancelButton: 'Cancel' });
if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
if (!fandom) {
toastr.error('Fandom name is required');
return;
}
const toast = toastr.info('Working, please wait...');
const result = await fetch('/api/plugins/fandom/scrape', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ fandom, filter }),
});
if (!result.ok) {
const error = await result.text();
throw new Error(error);
}
const data = await result.json();
toastr.clear(toast);
if (output === 'multi') {
const files = [];
for (const attachment of data) {
const file = new File([String(attachment.content).trim()], `${String(attachment.title).trim()}.txt`, { type: 'text/plain' });
files.push(file);
}
return files;
}
if (output === 'single') {
const combinedContent = data.map((a) => String(a.title).trim() + '\n\n' + String(a.content).trim()).join('\n\n\n\n');
const file = new File([combinedContent], `${fandom}.txt`, { type: 'text/plain' });
return [file];
}
return [];
}
}
/**
* Scrape transcript from a YouTube video.
* @implements {Scraper}
*/
class YouTubeScraper {
constructor() {
this.id = 'youtube';
this.name = 'YouTube';
this.description = 'Download a transcript from a YouTube video.';
this.iconClass = 'fa-solid fa-closed-captioning';
}
/**
* Check if the scraper is available.
* @returns {Promise<boolean>}
*/
async isAvailable() {
return true;
}
/**
* Parse the ID of a YouTube video from a URL.
* @param {string} url URL of the YouTube video
* @returns {string} ID of the YouTube video
*/
parseId(url){
const regex = /^.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/|shorts\/)|(?:(?:watch)?\?v(?:i)?=|&v(?:i)?=))([^#&?]*).*/;
const match = url.match(regex);
return (match?.length && match[1] ? match[1] : url);
}
/**
* Scrape transcript from a YouTube video.
* @returns {Promise<File[]>} File attachments scraped from the YouTube video
*/
async scrape() {
let lang = '';
const template = $(await renderExtensionTemplateAsync('attachments', 'youtube-scrape', {}));
const videoUrl = await callGenericPopup(template, POPUP_TYPE.INPUT, '', { wide: false, large: false, okButton: 'Scrape', cancelButton: 'Cancel', rows: 2 });
template.find('input[name="youtubeLanguageCode"]').on('input', function () {
lang = String($(this).val()).trim();
});
if (!videoUrl) {
return;
}
const id = this.parseId(String(videoUrl).trim());
const toast = toastr.info('Working, please wait...');
const result = await fetch('/api/serpapi/transcript', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ id, lang }),
});
if (!result.ok) {
const error = await result.text();
throw new Error(error);
}
const transcript = await result.text();
toastr.clear(toast);
const file = new File([transcript], `YouTube - ${id} - ${Date.now()}.txt`, { type: 'text/plain' });
return [file];
}
}
ScraperManager.registerDataBankScraper(new FileScraper());
ScraperManager.registerDataBankScraper(new Notepad());
ScraperManager.registerDataBankScraper(new WebScraper());
ScraperManager.registerDataBankScraper(new FandomScraper());
ScraperManager.registerDataBankScraper(new YouTubeScraper());

View File

@ -0,0 +1,115 @@
<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 class="flex-container flexFlowColumn alignItemsCenter flexNoGap">
<div class="avatar" title="If a custom avatar is not set, the user's default persona image will be displayed.">
<img src="img/ai4.png" alt="avatar">
</div>
<div class="flex-container alignItemsCenter">
<div class="userAvatarChange right_menu_button" title="Set a custom avatar.">
<i class="fa-fw fa-solid fa-image"></i>
</div>
<div class="userAvatarRemove right_menu_button" title="Remove a custom avatar.">
<i class="fa-fw fa-solid fa-trash"></i>
</div>
</div>
<form>
<input type="file" class="avatarUpload" accept="image/*" hidden>
</form>
</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>

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