@ -4,6 +4,7 @@ npm-debug.log
|
||||
readme*
|
||||
Start.bat
|
||||
/dist
|
||||
/backups/
|
||||
/backups
|
||||
cloudflared.exe
|
||||
access.log
|
||||
/data
|
||||
|
12
.eslintrc.js
@ -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
@ -25,6 +25,7 @@ public/stats.json
|
||||
/docker/config
|
||||
/docker/user
|
||||
/docker/extensions
|
||||
/docker/data
|
||||
.DS_Store
|
||||
public/settings.json
|
||||
/thumbnails
|
||||
|
16
Dockerfile
@ -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 \
|
||||
|
@ -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
|
||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 384 KiB |
Before Width: | Height: | Size: 487 KiB After Width: | Height: | Size: 487 KiB |
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 307 KiB |
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 318 KiB |
Before Width: | Height: | Size: 581 KiB After Width: | Height: | Size: 581 KiB |
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 561 KiB |
Before Width: | Height: | Size: 505 KiB After Width: | Height: | Size: 505 KiB |
Before Width: | Height: | Size: 501 KiB After Width: | Height: | Size: 501 KiB |
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 443 KiB |
Before Width: | Height: | Size: 480 KiB After Width: | Height: | Size: 480 KiB |
Before Width: | Height: | Size: 660 KiB After Width: | Height: | Size: 660 KiB |
Before Width: | Height: | Size: 371 KiB After Width: | Height: | Size: 371 KiB |
Before Width: | Height: | Size: 616 KiB After Width: | Height: | Size: 616 KiB |
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 305 KiB |
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 436 KiB |
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 426 KiB |
Before Width: | Height: | Size: 629 KiB After Width: | Height: | Size: 629 KiB |
Before Width: | Height: | Size: 656 KiB After Width: | Height: | Size: 656 KiB |
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 528 KiB |
@ -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"
|
||||
}
|
||||
]
|
||||
|
@ -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",
|
35
default/content/themes/Cappuccino.json
Normal 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
|
||||
}
|
35
default/content/themes/Dark Lite.json
Normal 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
|
||||
}
|
@ -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
|
||||
|
@ -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
@ -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...
|
||||
}
|
||||
}
|
@ -12,6 +12,9 @@
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/node_modules/*"
|
||||
"**/node_modules/*",
|
||||
"public/lib",
|
||||
"backups/*",
|
||||
"data/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
947
package-lock.json
generated
@ -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"
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
# Put images here to select them as a user persona avatar.
|
@ -1 +0,0 @@
|
||||
Put ambient audio files here.
|
@ -1 +0,0 @@
|
||||
Put bgm audio files here
|
@ -1 +0,0 @@
|
||||
Put blip audio files here
|
@ -1 +0,0 @@
|
||||
Put live2d model folders here
|
@ -1 +0,0 @@
|
||||
Put VRM animation files here
|
@ -1 +0,0 @@
|
||||
Put VRM model files here
|
@ -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
|
@ -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
@ -0,0 +1,5 @@
|
||||
.userAccount {
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
44
public/css/login.css
Normal 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);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
# Put Group Chat JSONL files here
|
@ -1 +0,0 @@
|
||||
# Put Group JSON files here
|
BIN
public/img/logo.png
Normal file
After Width: | Height: | Size: 23 KiB |
@ -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
13
public/lib/jszip.min.js
vendored
Normal file
81
public/login.html
Normal 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>
|
@ -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) {
|
||||
|
@ -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';
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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');
|
||||
|
@ -145,6 +145,8 @@ const extension_settings = {
|
||||
variables: {
|
||||
global: {},
|
||||
},
|
||||
attachments: [],
|
||||
character_attachments: {},
|
||||
};
|
||||
|
||||
let modules = [];
|
||||
|
9
public/scripts/extensions/attachments/buttons.html
Normal 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>
|
51
public/scripts/extensions/attachments/fandom-scrape.html
Normal 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>
|
6
public/scripts/extensions/attachments/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
|
||||
jQuery(async () => {
|
||||
const buttons = await renderExtensionTemplateAsync('attachments', 'buttons', {});
|
||||
$('#extensionsMenu').prepend(buttons);
|
||||
});
|
118
public/scripts/extensions/attachments/manager.html
Normal 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>
|
11
public/scripts/extensions/attachments/manifest.json
Normal 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"
|
||||
}
|
10
public/scripts/extensions/attachments/notepad.html
Normal 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>
|
29
public/scripts/extensions/attachments/style.css
Normal 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;
|
||||
}
|
3
public/scripts/extensions/attachments/web-scrape.html
Normal 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>
|
20
public/scripts/extensions/attachments/youtube-scrape.html
Normal 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>
|
@ -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') ||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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 {{text}} 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 {{text}} macro to specify the position of retrieved text."></textarea>
|
||||
<label for="vectors_position">Injection Position</label>
|
||||
<div class="radio_group">
|
||||
<label>
|
||||
|
@ -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
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
@ -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) {
|
||||
|
@ -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')));
|
||||
|
@ -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
@ -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());
|
115
public/scripts/templates/admin.html
Normal 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"> </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"> </span>
|
||||
</span>
|
||||
<span>
|
||||
<span data-i18n="Created:">Created:</span>
|
||||
<span class="userCreated"> </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>
|
5
public/scripts/templates/changeName.html
Normal file
@ -0,0 +1,5 @@
|
||||
<div class="flex-container">
|
||||
<h3>
|
||||
Enter a new display name:
|
||||
</h3>
|
||||
</div>
|
14
public/scripts/templates/changePassword.html
Normal 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>
|
26
public/scripts/templates/deleteUser.html
Normal 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>
|
13
public/scripts/templates/resetSettings.html
Normal 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>
|