VYPR
Critical severity9.6OSV Advisory· Published Oct 6, 2025· Updated Apr 15, 2026

CVE-2025-59159

CVE-2025-59159

Description

SillyTavern is a locally installed user interface that allows users to interact with text generation large language models, image generation engines, and text-to-speech voice models. In versions prior to 1.13.4, the web user interface for SillyTavern is susceptible to DNS rebinding, allowing attackers to perform actions like install malicious extensions, read chats, inject arbitrary HTML for phishing attacks, etc. The vulnerability has been patched in the version 1.13.4 by introducing a server configuration setting that enables a validation of host names in inbound HTTP requests according to the provided list of allowed hosts: hostWhitelist.enabled in config.yaml file or SILLYTAVERN_HOSTWHITELIST_ENABLED environment variable. While the setting is disabled by default to honor a wide variety of existing user configurations and maintain backwards compatibility, existing and new users are encouraged to review their server configurations and apply necessary changes to their setup, especially if hosting over the local network while not using SSL.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
sillytavernnpm
< 1.13.41.13.4

Affected products

1

Patches

2
6dabf12ed785

Merge pull request #4516 from SillyTavern/staging

74 files changed · +6086 1300
  • default/config.yaml+18 0 modified
    @@ -40,9 +40,15 @@ browserLaunch:
     port: 8000
     # -- SSL options --
     ssl:
    +  # Enable SSL/TLS encryption
       enabled: false
    +  # Path to certificate (relative to server root)
       certPath: "./certs/cert.pem"
    +  # Path to private key (relative to server root)
       keyPath: "./certs/privkey.pem"
    +  # Private key passphrase (leave empty if not needed)
    +  # For better security, use a CLI argument or an environment variable (SILLYTAVERN_SSL_KEYPASSPHRASE)
    +  keyPassphrase: ""
     # -- SECURITY CONFIGURATION --
     # Toggle whitelist mode
     whitelistMode: true
    @@ -88,6 +94,18 @@ autheliaAuth: false
     # the username and passwords for basic auth are the same as those
     # for the individual accounts
     perUserBasicAuth: false
    +# Host whitelist configuration. Recommended if you're using a listen mode
    +hostWhitelist:
    +  # Enable or disable host whitelisting
    +  enabled: false
    +  # Scan incoming requests for potential host header spoofing
    +  scan: true
    +  # List of allowed hosts. Do not include localhost or IPs, these are safe.
    +  # Use a dot to create subdomain patterns.
    +  # Examples:
    +  # - example.com
    +  # - .trycloudflare.com
    +  hosts: []
     
     # User session timeout *in seconds* (defaults to 24 hours).
     ## Set to a positive number to expire session after a certain time of inactivity
    
  • default/public/error/host-not-allowed.html+21 0 added
    @@ -0,0 +1,21 @@
    +<!DOCTYPE html>
    +<html>
    +
    +<head>
    +    <title>Forbidden</title>
    +</head>
    +
    +<body>
    +    <h1>Forbidden</h1>
    +    <p>
    +        If you are the system administrator, add the hostname you are accessing from to the
    +        host whitelist, or disable host whitelisting in the
    +        <code>config.yaml</code> file located in the root directory of your installation.
    +    </p>
    +    <hr />
    +    <p>
    +        <em>Access from this host is not allowed. This attempt has been logged.</em>
    +    </p>
    +</body>
    +
    +</html>
    
  • .gitignore+1 1 modified
    @@ -54,4 +54,4 @@ public/scripts/extensions/third-party
     .aider*
     .env
     /StartDev.bat
    -
    +yarn.lock
    
  • package.json+9 8 modified
    @@ -1,6 +1,6 @@
     {
         "dependencies": {
    -        "@adobe/css-tools": "^4.4.3",
    +        "@adobe/css-tools": "^4.4.4",
             "@agnai/sentencepiece-js": "^1.1.1",
             "@agnai/web-tokenizers": "^0.1.3",
             "@iconfu/svg-inject": "^1.2.3",
    @@ -32,9 +32,9 @@
             "archiver": "^7.0.1",
             "bing-translate-api": "^4.1.0",
             "body-parser": "^1.20.2",
    -        "bowser": "^2.11.0",
    +        "bowser": "^2.12.1",
             "bytes": "^3.1.2",
    -        "chalk": "^5.4.1",
    +        "chalk": "^5.6.0",
             "command-exists": "^1.2.9",
             "compression": "^1.8.1",
             "cookie-parser": "^1.4.6",
    @@ -54,6 +54,7 @@
             "handlebars": "^4.7.8",
             "helmet": "^8.1.0",
             "highlight.js": "^11.11.1",
    +        "host-validation-middleware": "^0.1.1",
             "html-entities": "^2.6.0",
             "iconv-lite": "^0.6.3",
             "ip-matching": "^2.1.2",
    @@ -64,7 +65,7 @@
             "lodash": "^4.17.21",
             "mime-types": "^3.0.1",
             "moment": "^2.30.1",
    -        "morphdom": "^2.7.5",
    +        "morphdom": "^2.7.7",
             "multer": "^2.0.2",
             "node-fetch": "^3.3.2",
             "node-persist": "^4.0.4",
    @@ -80,14 +81,14 @@
             "sillytavern-transformers": "2.14.6",
             "simple-git": "^3.28.0",
             "slidetoggle": "^4.0.0",
    -        "tiktoken": "^1.0.21",
    +        "tiktoken": "^1.0.22",
             "url-join": "^5.0.0",
             "vectra": "^0.2.2",
             "wavefile": "^11.0.0",
             "webpack": "^5.98.0",
             "write-file-atomic": "^5.0.1",
             "ws": "^8.18.3",
    -        "yaml": "^2.8.0",
    +        "yaml": "^2.8.1",
             "yargs": "^17.7.1",
             "yauzl": "^3.2.0"
         },
    @@ -112,7 +113,7 @@
             "type": "git",
             "url": "https://github.com/SillyTavern/SillyTavern.git"
         },
    -    "version": "1.13.3",
    +    "version": "1.13.4",
         "scripts": {
             "start": "node server.js",
             "debug": "node --inspect server.js",
    @@ -145,7 +146,7 @@
             "@types/cors": "^2.8.19",
             "@types/deno": "^2.3.0",
             "@types/express": "^4.17.23",
    -        "@types/jquery": "^3.5.32",
    +        "@types/jquery": "^3.5.33",
             "@types/jquery-cropper": "^1.0.4",
             "@types/jquery.transit": "^0.9.33",
             "@types/jqueryui": "^1.12.24",
    
  • package-lock.json+40 30 modified
    @@ -1,16 +1,16 @@
     {
         "name": "sillytavern",
    -    "version": "1.13.3",
    +    "version": "1.13.4",
         "lockfileVersion": 3,
         "requires": true,
         "packages": {
             "": {
                 "name": "sillytavern",
    -            "version": "1.13.3",
    +            "version": "1.13.4",
                 "hasInstallScript": true,
                 "license": "AGPL-3.0",
                 "dependencies": {
    -                "@adobe/css-tools": "^4.4.3",
    +                "@adobe/css-tools": "^4.4.4",
                     "@agnai/sentencepiece-js": "^1.1.1",
                     "@agnai/web-tokenizers": "^0.1.3",
                     "@iconfu/svg-inject": "^1.2.3",
    @@ -42,9 +42,9 @@
                     "archiver": "^7.0.1",
                     "bing-translate-api": "^4.1.0",
                     "body-parser": "^1.20.2",
    -                "bowser": "^2.11.0",
    +                "bowser": "^2.12.1",
                     "bytes": "^3.1.2",
    -                "chalk": "^5.4.1",
    +                "chalk": "^5.6.0",
                     "command-exists": "^1.2.9",
                     "compression": "^1.8.1",
                     "cookie-parser": "^1.4.6",
    @@ -64,6 +64,7 @@
                     "handlebars": "^4.7.8",
                     "helmet": "^8.1.0",
                     "highlight.js": "^11.11.1",
    +                "host-validation-middleware": "^0.1.1",
                     "html-entities": "^2.6.0",
                     "iconv-lite": "^0.6.3",
                     "ip-matching": "^2.1.2",
    @@ -74,7 +75,7 @@
                     "lodash": "^4.17.21",
                     "mime-types": "^3.0.1",
                     "moment": "^2.30.1",
    -                "morphdom": "^2.7.5",
    +                "morphdom": "^2.7.7",
                     "multer": "^2.0.2",
                     "node-fetch": "^3.3.2",
                     "node-persist": "^4.0.4",
    @@ -90,14 +91,14 @@
                     "sillytavern-transformers": "2.14.6",
                     "simple-git": "^3.28.0",
                     "slidetoggle": "^4.0.0",
    -                "tiktoken": "^1.0.21",
    +                "tiktoken": "^1.0.22",
                     "url-join": "^5.0.0",
                     "vectra": "^0.2.2",
                     "wavefile": "^11.0.0",
                     "webpack": "^5.98.0",
                     "write-file-atomic": "^5.0.1",
                     "ws": "^8.18.3",
    -                "yaml": "^2.8.0",
    +                "yaml": "^2.8.1",
                     "yargs": "^17.7.1",
                     "yauzl": "^3.2.0"
                 },
    @@ -114,7 +115,7 @@
                     "@types/cors": "^2.8.19",
                     "@types/deno": "^2.3.0",
                     "@types/express": "^4.17.23",
    -                "@types/jquery": "^3.5.32",
    +                "@types/jquery": "^3.5.33",
                     "@types/jquery-cropper": "^1.0.4",
                     "@types/jquery.transit": "^0.9.33",
                     "@types/jqueryui": "^1.12.24",
    @@ -149,9 +150,9 @@
                 }
             },
             "node_modules/@adobe/css-tools": {
    -            "version": "4.4.3",
    -            "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz",
    -            "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==",
    +            "version": "4.4.4",
    +            "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
    +            "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
                 "license": "MIT"
             },
             "node_modules/@agnai/sentencepiece-js": {
    @@ -1911,9 +1912,9 @@
                 "license": "MIT"
             },
             "node_modules/@types/jquery": {
    -            "version": "3.5.32",
    -            "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.32.tgz",
    -            "integrity": "sha512-b9Xbf4CkMqS02YH8zACqN1xzdxc3cO735Qe5AbSUFmyOiaWAbcpqh9Wna+Uk0vgACvoQHpWDg2rGdHkYPLmCiQ==",
    +            "version": "3.5.33",
    +            "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz",
    +            "integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==",
                 "dev": true,
                 "license": "MIT",
                 "dependencies": {
    @@ -2896,9 +2897,9 @@
                 "license": "ISC"
             },
             "node_modules/bowser": {
    -            "version": "2.11.0",
    -            "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
    -            "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==",
    +            "version": "2.12.1",
    +            "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz",
    +            "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==",
                 "license": "MIT"
             },
             "node_modules/brace-expansion": {
    @@ -3126,9 +3127,9 @@
                 }
             },
             "node_modules/chalk": {
    -            "version": "5.4.1",
    -            "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
    -            "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
    +            "version": "5.6.0",
    +            "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
    +            "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
                 "license": "MIT",
                 "engines": {
                     "node": "^12.17.0 || ^14.13 || >=16.0.0"
    @@ -5249,6 +5250,15 @@
                     "node": ">=12.0.0"
                 }
             },
    +        "node_modules/host-validation-middleware": {
    +            "version": "0.1.1",
    +            "resolved": "https://registry.npmjs.org/host-validation-middleware/-/host-validation-middleware-0.1.1.tgz",
    +            "integrity": "sha512-fakcpp+x4nbP0fACY5gaHWpaOfstq3w8uB6wvhbPBLqH9GV/tdiM9Ht5mclZVbUuPLGBw1bkH5yyTD6HZq057g==",
    +            "license": "MIT",
    +            "engines": {
    +                "node": "^18.0.0 || >=20.0.0"
    +            }
    +        },
             "node_modules/html-entities": {
                 "version": "2.6.0",
                 "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
    @@ -6178,9 +6188,9 @@
                 }
             },
             "node_modules/morphdom": {
    -            "version": "2.7.5",
    -            "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.5.tgz",
    -            "integrity": "sha512-z6bfWFMra7kBqDjQGHud1LSXtq5JJC060viEkQFMBX6baIecpkNr2Ywrn2OQfWP3rXiNFQRPoFjD8/TvJcWcDg==",
    +            "version": "2.7.7",
    +            "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.7.tgz",
    +            "integrity": "sha512-04GmsiBcalrSCNmzfo+UjU8tt3PhZJKzcOy+r1FlGA7/zri8wre3I1WkYN9PT3sIeIKfW9bpyElA+VzOg2E24g==",
                 "license": "MIT"
             },
             "node_modules/ms": {
    @@ -8001,9 +8011,9 @@
                 "license": "MIT"
             },
             "node_modules/tiktoken": {
    -            "version": "1.0.21",
    -            "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.21.tgz",
    -            "integrity": "sha512-/kqtlepLMptX0OgbYD9aMYbM7EFrMZCL7EoHM8Psmg2FuhXoo/bH64KqOiZGGwa6oS9TPdSEDKBnV2LuB8+5vQ==",
    +            "version": "1.0.22",
    +            "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz",
    +            "integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==",
                 "license": "MIT"
             },
             "node_modules/timm": {
    @@ -8610,9 +8620,9 @@
                 }
             },
             "node_modules/yaml": {
    -            "version": "2.8.0",
    -            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
    -            "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
    +            "version": "2.8.1",
    +            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
    +            "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
                 "license": "ISC",
                 "bin": {
                     "yaml": "bin.mjs"
    
  • public/css/backgrounds.css+229 0 added
    @@ -0,0 +1,229 @@
    +/* Main Page Backgrounds */
    +#bg1,
    +#bg_custom {
    +    background-repeat: no-repeat;
    +    background-attachment: fixed;
    +    background-size: cover;
    +    position: absolute;
    +    width: 100%;
    +    height: 100%;
    +    transition: background-image var(--animation-duration-3x) ease-in-out;
    +}
    +
    +/* Fitting options */
    +#background_fitting {
    +    max-width: 6em;
    +}
    +
    +/* Fill/Cover - scales to fill width while maintaining aspect ratio */
    +#bg1.cover,
    +#bg_custom.cover {
    +    background-size: cover;
    +    background-position: center;
    +}
    +
    +/* Fit/Contain - shows entire image maintaining aspect ratio */
    +#bg1.contain,
    +#bg_custom.contain {
    +    background-size: contain;
    +    background-position: center;
    +    background-repeat: no-repeat;
    +}
    +
    +/* Stretch - stretches to fill entire space */
    +#bg1.stretch,
    +#bg_custom.stretch {
    +    background-size: 100% 100%;
    +}
    +
    +/* Center - centers without scaling */
    +#bg1.center,
    +#bg_custom.center {
    +    background-size: auto;
    +    background-position: center;
    +    background-repeat: no-repeat;
    +}
    +
    +body.reduced-motion #bg1,
    +body.reduced-motion #bg_custom {
    +    transition: none;
    +}
    +
    +#bg1 {
    +    background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=');
    +    z-index: -3;
    +}
    +
    +#bg_custom {
    +    background-image: none;
    +    z-index: -2;
    +}
    +
    +.bg_example.flex-container.locked:not(:focus-visible) {
    +    outline-color: var(--golden);
    +}
    +
    +/* This is the main flex container for the entire drawer */
    +#Backgrounds.drawer-content.openDrawer.bg-drawer-layout {
    +    display: flex;
    +    flex-direction: column;
    +    height: calc(100vh - var(--topBarBlockSize));
    +    max-height: calc(100vh - var(--topBarBlockSize));
    +    height: calc(100dvh - var(--topBarBlockSize));
    +    max-height: calc(100dvh - var(--topBarBlockSize));
    +    overflow: hidden;
    +    width: var(--sheldWidth);
    +    max-width: var(--sheldWidth);
    +    padding: 0;
    +}
    +
    +#bg-header-fixed {
    +    flex-shrink: 0;
    +    padding: 5px;
    +    background-color: var(--SmartThemeBlurTintColor);
    +    border-bottom: 1px solid var(--SmartThemeBorderColor);
    +}
    +
    +#bg-header-fixed>.flex-container {
    +    display: flex;
    +    align-items: center;
    +    gap: 5px;
    +}
    +
    +#bg-scrollable-content {
    +    flex-grow: 1;
    +    overflow-y: auto;
    +    overflow-x: hidden;
    +    padding: 0 5px 5px;
    +}
    +
    +#bg-filter {
    +    font-size: calc(var(--mainFontSize) * 0.95);
    +}
    +
    +/* Thumbnail Menu & Buttons */
    +.bg_example .mobile-only-menu-toggle {
    +    display: none;
    +}
    +
    +.bg_example.flex-container {
    +    width: 30%;
    +    max-width: 200px;
    +    margin: 5px;
    +    aspect-ratio: 16/9;
    +    cursor: pointer;
    +    box-shadow: 0 0 7px var(--black50a);
    +
    +    position: relative;
    +    overflow: hidden;
    +    border-radius: 8px;
    +    border: 0px solid transparent;
    +    outline: 2px solid var(--SmartThemeBorderColor);
    +    outline-offset: -1px;
    +}
    +
    +.bg_example.flex-container:focus-visible {
    +    outline-offset: inherit;
    +}
    +
    +.bg_example_img {
    +    position: absolute;
    +    top: -2px;
    +    left: -2px;
    +    right: -2px;
    +    bottom: -2px;
    +
    +    background-image: inherit;
    +
    +    background-size: cover;
    +    background-position: center;
    +}
    +
    +.bg_example .jg-menu {
    +    display: flex;
    +    position: absolute;
    +    top: 2px;
    +    right: 2px;
    +    background-color: rgba(0, 0, 0, 0.5);
    +    border-radius: 8px;
    +    gap: 3px;
    +    padding: 3px 5px;
    +    z-index: 3;
    +    backdrop-filter: blur(4px);
    +    border: 1px solid var(--SmartThemeBorderColor);
    +    justify-items: center;
    +    align-items: center;
    +
    +    opacity: 0;
    +    visibility: hidden;
    +    transform: scale(0.9);
    +    transform-origin: center;
    +    transition: opacity var(--animation-duration) ease-out, visibility var(--animation-duration) ease-out, transform var(--animation-duration) ease-out;
    +}
    +
    +.bg_example:hover .jg-menu,
    +.bg_example:focus-within .jg-menu {
    +    opacity: 1;
    +    visibility: visible;
    +    transform: scale(1);
    +}
    +
    +.bg_example .jg-button {
    +    display: flex;
    +    width: 30px;
    +    height: 30px;
    +    align-items: center;
    +    justify-content: center;
    +    color: white;
    +    padding: 5px;
    +    font-size: 1.1em;
    +    border-radius: 6px;
    +    transition: background-color var(--animation-duration) ease;
    +}
    +
    +.bg_example .jg-button:hover {
    +    background-color: rgba(255, 255, 255, 0.2);
    +}
    +
    +.bg_example .jg-unlock {
    +    display: none;
    +}
    +
    +.bg_example.locked .jg-lock {
    +    display: none;
    +}
    +
    +.bg_example.locked .jg-unlock {
    +    display: flex;
    +}
    +
    +.bg_example:not([custom="true"]) .jg-copy,
    +.bg_example[custom="true"] .jg-edit {
    +    display: none;
    +}
    +
    +/* Thumbnail Title */
    +.bg_example .BGSampleTitle {
    +    position: absolute;
    +    bottom: 0;
    +    left: 0;
    +    right: 0;
    +    background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
    +    color: var(--SmartThemeBodyColor);
    +    font-size: 0.9em;
    +    font-weight: 600;
    +    padding: 0px 6px 2px;
    +    text-align: center;
    +    white-space: nowrap;
    +    overflow: hidden;
    +    text-overflow: ellipsis;
    +    opacity: 0;
    +    transition: opacity var(--animation-duration) ease-in-out;
    +    pointer-events: none;
    +    border-radius: 0 0 8px 8px;
    +}
    +
    +.bg_example:hover .BGSampleTitle,
    +.bg_example:focus-within .BGSampleTitle {
    +    opacity: 1;
    +}
    
  • public/css/mobile-styles.css+81 4 modified
    @@ -25,6 +25,87 @@
             font-size: 15px;
         }
     
    +    #Backgrounds .bg_example .BGSampleTitle {
    +        opacity: 1;
    +        bottom: 0px;
    +    }
    +
    +    .bg_example:hover .jg-menu,
    +    .bg_example:focus-within .jg-menu {
    +        display: none;
    +    }
    +
    +    .bg_example.mobile-menu-open .jg-menu {
    +        display: flex;
    +        z-index: 4;
    +    }
    +
    +    .bg_example .mobile-only-menu-toggle {
    +        display: flex;
    +        align-items: center;
    +        justify-content: center;
    +        position: absolute;
    +        top: 5px;
    +        right: 5px;
    +        width: 30px;
    +        height: 30px;
    +        background-color: rgba(0, 0, 0, 0.4);
    +        color: white;
    +        border-radius: 6px;
    +        z-index: 3;
    +        cursor: pointer;
    +        backdrop-filter: blur(2px);
    +    }
    +
    +    #bg-header-controls {
    +        flex-wrap: wrap;
    +        row-gap: 10px;
    +    }
    +
    +    #bg-header-fixed>.flex-container {
    +        flex-wrap: wrap;
    +        row-gap: 0px;
    +    }
    +
    +    #Backgrounds:not(.selection-mode-active) #bg-header-fixed>.flex-container::after {
    +        content: '';
    +        order: 1;
    +        flex-basis: 100%;
    +        height: 0;
    +    }
    +
    +    /* --- Row 1 Item --- */
    +    #bg-header-fixed #bg-header-title {
    +        order: 1;
    +        flex-grow: 1;
    +    }
    +
    +    #bg-header-fixed #background_fitting,
    +    #bg-header-fixed #auto_background {
    +        order: 1;
    +    }
    +
    +    /* --- Row 2 Item --- */
    +    #bg-header-fixed #bg-filter {
    +        order: 2;
    +        flex-grow: 1;
    +        min-width: 0;
    +    }
    +
    +    /* --- Row 3 Item --- */
    +    #bg-header-fixed #add_background_button_top {
    +        order: 3;
    +        width: 100%;
    +        text-align: center;
    +        padding-top: 0.5em;
    +        padding-bottom: 0.5em;
    +    }
    +
    +    #Backgrounds.drawer-content.openDrawer.bg-drawer-layout {
    +        width: 100dvw;
    +        max-width: 100dvw;
    +    }
    +
         #extensions_settings,
         #extensions_settings2 {
             width: 100% !important;
    @@ -412,10 +493,6 @@
             flex-basis: max(calc(100% / 2 - 10px), 180px);
         }
     
    -    .BGSampleTitle {
    -        display: none;
    -    }
    -
         .tag.excluded:after {
             top: unset;
             bottom: unset;
    
  • public/css/popup.css+25 8 modified
    @@ -26,7 +26,6 @@ dialog {
     
         /* Fix weird animation issue with font-scaling during popup open */
         backface-visibility: hidden;
    -    transform: translateZ(0);
         -webkit-font-smoothing: subpixel-antialiased;
     
         /* Variables setup */
    @@ -93,6 +92,11 @@ dialog {
         animation: fade-in var(--popup-animation-speed) ease-in-out;
     }
     
    +/* Fix toast container snapping into the backdrop while the animation is running */
    +.popup[opening] #toast-container {
    +    visibility: hidden;
    +}
    +
     /* Open state of the dialog */
     .popup[open] {
         color: var(--SmartThemeBodyColor);
    @@ -118,17 +122,30 @@ body.no-blur .popup[open]::backdrop {
         animation: fade-out var(--popup-animation-speed) ease-in-out;
     }
     
    -.popup #toast-container {
    -    /* Fix toastr in dialogs by actually placing it at the top of the screen via transform */
    -    height: 100dvh;
    -    top: calc(50% + var(--topBarBlockSize));
    -    left: 50%;
    -    transform: translate(-50%, -50%);
    +/* Edge inset to match Toastr default spacing */
    +:root {
    +    --toast-edge: 12px;
    +}
     
    -    /* Fix text align, popups are centered by default. toasts should not. */
    +.popup #toast-container {
    +    /* Popups are centered by default; toasts should not be */
         text-align: left;
     }
     
    +/* Per-position position adjustments caused by the top bar, inside the popup */
    +.popup #toast-container.toast-top-left {
    +    top: calc(var(--toast-edge) + var(--topBarBlockSize));
    +}
    +
    +.popup #toast-container.toast-top-center {
    +    /* toastr in core does not have a top offset on center, so we don't do that either in popups */
    +    top: var(--topBarBlockSize);
    +}
    +
    +.popup #toast-container.toast-top-right {
    +    top: calc(var(--toast-edge) + var(--topBarBlockSize));
    +}
    +
     .popup-crop-wrap {
         margin: 10px auto;
         max-height: 75vh;
    
  • public/global.d.ts+11 11 modified
    @@ -5,20 +5,20 @@ import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyAp
     
     declare global {
         // Custom types
    -    declare type InstructSettings = typeof power_user.instruct;
    -    declare type ContextSettings = typeof power_user.context;
    -    declare type ReasoningSettings = typeof power_user.reasoning;
    +    type InstructSettings = typeof power_user.instruct;
    +    type ContextSettings = typeof power_user.context;
    +    type ReasoningSettings = typeof power_user.reasoning;
     
         // Global namespace modules
         interface Window {
             ai: any;
         }
     
    -    declare var pdfjsLib;
    -    declare var ePub;
    -    declare var quickReplyApi: QuickReplyApi;
    +    var pdfjsLib;
    +    var ePub;
    +    var quickReplyApi: QuickReplyApi;
     
    -    declare var SillyTavern: {
    +    var SillyTavern: {
             getContext(): typeof getContext;
             llm: any;
             libs: typeof libs;
    @@ -63,7 +63,7 @@ declare global {
          * @param lang Target language
          * @param provider Translation provider
          */
    -    async function translate(text: string, lang: string, provider: string = null): Promise<string>;
    +    function translate(text: string, lang: string, provider?: string | null): Promise<string>;
     
         interface ConvertVideoArgs {
             buffer: Uint8Array;
    @@ -76,9 +76,9 @@ declare global {
          */
         function convertVideoToAnimatedWebp(args: ConvertVideoArgs): Promise<Uint8Array>;
     
    -    interface ColorPickerEvent extends JQuery.ChangeEvent<HTMLElement> {
    +    type ColorPickerEvent = Omit<JQuery.ChangeEvent<HTMLElement>, "detail"> & {
             detail: {
                 rgba: string;
    -        };
    -    }
    +        }
    +    };
     }
    
  • public/img/azure_openai.svg+1 0 added
    @@ -0,0 +1 @@
    +<svg id="uuid-adbdae8e-5a41-46d1-8c18-aa73cdbfee32" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" height="100px" width="100px" transform="rotate(0) scale(1, 1)"><path d="m0,2.7v12.6c0,1.491,1.209,2.7,2.7,2.7h12.6c1.491,0,2.7-1.209,2.7-2.7V2.7c0-1.491-1.209-2.7-2.7-2.7H2.7C1.209,0,0,1.209,0,2.7ZM10.8,0v3.6c0,3.976,3.224,7.2,7.2,7.2h-3.6c-3.976,0-7.199,3.222-7.2,7.198v-3.598c0-3.976-3.224-7.2-7.2-7.2h3.6c3.976,0,7.2-3.224,7.2-7.2Z" stroke-width="0"/></svg>
    
  • public/img/electronhub.svg+1 0 added
    @@ -0,0 +1 @@
    +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="118.66667" height="177.33333" viewBox="0 0 89 133"><path d="M14.5 1.8c-6.3 3-11.3 8.8-13.2 15.1C-.1 21.8-.2 27.6.6 70.6l.9 48.2 3.1 4.4c1.9 2.6 5.3 5.4 8.4 7l5.2 2.8h26.7c14.8 0 28.2-.5 30.2-1 5.4-1.5 11.6-8.6 12.4-14.3 1-6.5-.2-10.4-4.7-15.3-5.1-5.5-8.3-6.4-23.3-6.4C46.2 96 43 94.9 43 90.5c0-4.3 3.3-5.5 15.4-5.5 13 0 18-1.8 22.7-8.3 5.4-7.5 5-14.4-1.3-21.3-5.1-5.5-11-7.4-22.8-7.4-9.2 0-13-1.5-13-5 0-3.9 3.6-5 16.9-5 14.5 0 19.4-1.6 23.7-7.9 5.4-7.9 5.2-15.9-.6-22.5-5.4-6.1-6.7-6.4-37.5-7.1-26.3-.6-28.2-.5-32 1.3z"/></svg>
    \ No newline at end of file
    
  • public/index.html+104 36 modified
    @@ -652,7 +652,7 @@
                                             <input type="number" id="openai_max_tokens" name="openai_max_tokens" class="text_pole" min="1" max="65536">
                                         </div>
                                     </div>
    -                                <div class="range-block" data-source="openai,custom,xai,aimlapi,moonshot">
    +                                <div class="range-block" data-source="openai,custom,xai,aimlapi,moonshot,azure_openai">
                                         <div class="range-block-title" data-i18n="Multiple swipes per generation">
                                             Multiple swipes per generation
                                         </div>
    @@ -691,7 +691,7 @@
                                             </span>
                                         </div>
                                     </div>
    -                                <div class="range-block" data-source="openai,claude,aimlapi,openrouter,ai21,makersuite,vertexai,mistralai,custom,cohere,perplexity,groq,nanogpt,deepseek,xai,pollinations,moonshot,fireworks,cometapi">
    +                                <div class="range-block" data-source="openai,claude,aimlapi,openrouter,ai21,makersuite,vertexai,mistralai,custom,cohere,perplexity,groq,electronhub,nanogpt,deepseek,xai,pollinations,moonshot,fireworks,cometapi,azure_openai">
                                         <div class="range-block-title" data-i18n="Temperature">
                                             Temperature
                                         </div>
    @@ -704,7 +704,7 @@
                                             </div>
                                         </div>
                                     </div>
    -                                <div class="range-block" data-source="openai,aimlapi,openrouter,custom,cohere,perplexity,groq,mistralai,nanogpt,deepseek,xai,pollinations,moonshot,fireworks,cometapi">
    +                                <div class="range-block" data-source="openai,aimlapi,openrouter,custom,cohere,perplexity,groq,mistralai,electronhub,nanogpt,deepseek,xai,pollinations,moonshot,fireworks,cometapi,azure_openai">
                                         <div class="range-block-title" data-i18n="Frequency Penalty">
                                             Frequency Penalty
                                         </div>
    @@ -717,7 +717,7 @@
                                             </div>
                                         </div>
                                     </div>
    -                                <div class="range-block" data-source="openai,aimlapi,openrouter,custom,cohere,perplexity,groq,mistralai,nanogpt,deepseek,xai,pollinations,moonshot,fireworks,cometapi">
    +                                <div class="range-block" data-source="openai,aimlapi,openrouter,custom,cohere,perplexity,groq,mistralai,electronhub,nanogpt,deepseek,xai,pollinations,moonshot,fireworks,cometapi,azure_openai">
                                         <div class="range-block-title" data-i18n="Presence Penalty">
                                             Presence Penalty
                                         </div>
    @@ -730,7 +730,7 @@
                                             </div>
                                         </div>
                                     </div>
    -                                <div class="range-block" data-source="claude,aimlapi,openrouter,makersuite,vertexai,cohere,perplexity">
    +                                <div class="range-block" data-source="claude,aimlapi,openrouter,makersuite,vertexai,cohere,perplexity,electronhub">
                                         <div class="range-block-title" data-i18n="Top K">
                                             Top K
                                         </div>
    @@ -743,7 +743,7 @@
                                             </div>
                                         </div>
                                     </div>
    -                                <div class="range-block" data-source="openai,claude,aimlapi,openrouter,ai21,makersuite,vertexai,mistralai,custom,cohere,perplexity,groq,nanogpt,deepseek,xai,pollinations,moonshot,fireworks,cometapi">
    +                                <div class="range-block" data-source="openai,claude,aimlapi,openrouter,ai21,makersuite,vertexai,mistralai,custom,cohere,perplexity,groq,electronhub,nanogpt,deepseek,xai,pollinations,moonshot,fireworks,cometapi,azure_openai">
                                         <div class="range-block-title" data-i18n="Top P">
                                             Top P
                                         </div>
    @@ -980,7 +980,7 @@
                                             </div>
                                         </div>
                                     </div>
    -                                <div class="range-block" data-source="openai,openrouter,mistralai,custom,cohere,groq,nanogpt,xai,pollinations,aimlapi,makersuite,vertexai">
    +                                <div class="range-block" data-source="openai,openrouter,mistralai,custom,cohere,groq,electronhub,nanogpt,xai,pollinations,aimlapi,makersuite,vertexai,azure_openai">
                                         <div class="range-block-title justifyLeft" data-i18n="Seed">
                                             Seed
                                         </div>
    @@ -1970,7 +1970,7 @@ <h4 class="range-block-title justifyCenter">
                                                 </span>
                                             </div>
                                         </div>
    -                                    <div class="range-block" data-source="makersuite,vertexai,aimlapi,openrouter,claude,xai,nanogpt">
    +                                    <div class="range-block" data-source="makersuite,vertexai,aimlapi,openrouter,claude,xai,electronhub,nanogpt">
                                             <label for="openai_enable_web_search" class="checkbox_label flexWrap widthFreeExpand">
                                                 <input id="openai_enable_web_search" type="checkbox" />
                                                 <span data-i18n="Enable web search">Enable web search</span>
    @@ -1984,7 +1984,7 @@ <h4 class="range-block-title justifyCenter">
                                                 </b>
                                             </div>
                                         </div>
    -                                    <div class="range-block" data-source="openai,cohere,mistralai,custom,claude,aimlapi,openrouter,groq,deepseek,makersuite,vertexai,ai21,xai,pollinations,moonshot,fireworks,cometapi">
    +                                    <div class="range-block" data-source="openai,cohere,mistralai,custom,claude,aimlapi,openrouter,groq,deepseek,makersuite,vertexai,ai21,xai,pollinations,moonshot,fireworks,cometapi,electronhub,azure_openai">
                                             <label for="openai_function_calling" class="checkbox_label flexWrap widthFreeExpand">
                                                 <input id="openai_function_calling" type="checkbox" />
                                                 <span data-i18n="Enable function calling">Enable function calling</span>
    @@ -1999,7 +1999,7 @@ <h4 class="range-block-title justifyCenter">
                                                 <strong data-i18n="enable_functions_desc_4">Not supported when Prompt Post-Processing with "no tools" is used!</strong>
                                             </div>
                                         </div>
    -                                    <div class="range-block" data-source="openai,aimlapi,openrouter,mistralai,makersuite,vertexai,claude,custom,xai,pollinations,moonshot,cohere,cometapi">
    +                                    <div class="range-block" data-source="openai,aimlapi,openrouter,mistralai,makersuite,vertexai,claude,custom,xai,pollinations,moonshot,cohere,cometapi,nanogpt,electronhub,azure_openai">
                                             <label for="openai_image_inlining" class="checkbox_label flexWrap widthFreeExpand">
                                                 <input id="openai_image_inlining" type="checkbox" />
                                                 <span data-i18n="Send inline images">Send inline images</span>
    @@ -2015,7 +2015,7 @@ <h4 class="range-block-title justifyCenter">
                                                 <code><i class="fa-solid fa-wand-magic-sparkles"></i></code>
                                                 <span data-i18n="image_inlining_hint_3">menu to attach an image file to the chat.</span>
                                             </div>
    -                                        <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom,xai,pollinations,cohere">
    +                                        <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom,xai,pollinations,cohere,cometapi,nanogpt,moonshot,aimlapi,openrouter,mistralai,electronhub,azure_openai">
                                                 <div class="flex-container oneline-dropdown">
                                                     <label for="openai_inline_image_quality" data-i18n="Inline Image Quality">
                                                         Inline Image Quality
    @@ -2077,7 +2077,7 @@ <h4 class="range-block-title justifyCenter">
                                                 </span>
                                             </div>
                                         </div>
    -                                    <div class="range-block" data-source="deepseek,aimlapi,openrouter,custom,claude,xai,makersuite,vertexai,pollinations,moonshot,mistralai,fireworks,cometapi">
    +                                    <div class="range-block" data-source="deepseek,aimlapi,openrouter,custom,claude,xai,makersuite,vertexai,pollinations,moonshot,mistralai,fireworks,cometapi,electronhub,azure_openai">
                                             <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
                                                 <input id="openai_show_thoughts" type="checkbox" />
                                                 <span data-i18n="Request model reasoning">Request model reasoning</span>
    @@ -2091,7 +2091,7 @@ <h4 class="range-block-title justifyCenter">
                                                 </span>
                                             </div>
                                         </div>
    -                                    <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom,claude,xai,makersuite,vertexai,aimlapi,openrouter,pollinations,perplexity,cometapi">
    +                                    <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom,claude,xai,makersuite,vertexai,aimlapi,openrouter,pollinations,perplexity,cometapi,electronhub,azure_openai">
                                             <div class="flex-container oneline-dropdown" title="Constrains effort on reasoning for reasoning models.&#10;Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response." data-i18n="[title]Constrains effort on reasoning for reasoning models.">
                                                 <label for="openai_reasoning_effort">
                                                     <span data-i18n="Reasoning Effort">Reasoning Effort</span>
    @@ -2105,7 +2105,7 @@ <h4 class="range-block-title justifyCenter">
                                                     <option data-i18n="openai_reasoning_effort_high" value="high">High</option>
                                                     <option data-i18n="openai_reasoning_effort_maximum" value="max">Maximum</option>
                                                 </select>
    -                                            <div class="toggle-description justifyLeft marginBot5" data-source="openai,custom,xai,aimlapi,openrouter,perplexity" data-i18n="OpenAI-style options: low, medium, high. Minimum and maximum are aliased to low and high. Auto does not send an effort level.">
    +                                            <div class="toggle-description justifyLeft marginBot5" data-source="openai,custom,xai,aimlapi,openrouter,perplexity,electronhub,azure_openai" data-i18n="OpenAI-style options: low, medium, high. Minimum and maximum are aliased to low and high. Auto does not send an effort level.">
                                                     OpenAI-style options: low, medium, high. Minimum and maximum are aliased to low and high. Auto does not send an effort level.
                                                 </div>
                                                 <div class="toggle-description justifyLeft marginBot5" data-source="claude" data-i18n="Allocates a portion of the response length for thinking (min: 1024 tokens, low: 10%, medium: 25%, high: 50%, max: 95%), but minimum 1024 tokens. Auto does not request thinking.">
    @@ -2144,7 +2144,7 @@ <h4 class="range-block-title justifyCenter">
                                             </div>
                                         </div>
                                     </div>
    -                                <div class="range-block m-t-1" data-source="openai,aimlapi,openrouter,custom">
    +                                <div class="range-block m-t-1" data-source="openai,aimlapi,openrouter,custom,azure_openai">
                                         <div id="logit_bias_openai" class="range-block-title openai_restorable" data-i18n="Logit Bias">
                                             Logit Bias
                                         </div>
    @@ -2802,11 +2802,13 @@ <h4 class="margin0" data-i18n="Chat Completion Source">
                                 <optgroup>
                                     <option value="ai21">AI21</option>
                                     <option value="aimlapi">AI/ML API</option>
    +                                <option value="azure_openai">Azure OpenAI</option>
                                     <option value="claude">Claude</option>
                                     <option value="cohere">Cohere</option>
                                     <!-- Temporarily disabled. -->
                                     <!-- <option value="cometapi">CometAPI</option> -->
                                     <option value="deepseek">DeepSeek</option>
    +                                <option value="electronhub">Electron Hub</option>
                                     <option value="fireworks">Fireworks AI</option>
                                     <option value="groq">Groq</option>
                                     <option value="makersuite">Google AI Studio</option>
    @@ -3178,6 +3180,7 @@ <h4 data-i18n="Google Model">Google Model</h4>
                                             <option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
                                             <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
                                             <option value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>
    +                                        <option value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
                                         </optgroup>
                                         <optgroup label="Gemini 2.0">
                                             <option value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-pro-exp-03-25</option>
    @@ -3356,6 +3359,7 @@ <h4 data-i18n="Google Model">Google Model</h4>
                                             <option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
                                             <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
                                             <option value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>
    +                                        <option value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
                                         </optgroup>
                                         <optgroup label="Gemini 2.0">
                                             <option value="gemini-2.0-flash-exp" data-mode="full">gemini-2.0-flash-exp</option>
    @@ -3467,6 +3471,20 @@ <h4 data-i18n="Groq Model">Groq Model</h4>
                                     <option value="mistral-saba-24b">mistral-saba-24b</option>
                                 </select>
                             </div>
    +                        <div id="electronhub_form" data-source="electronhub">
    +                            <h4 data-i18n="Electron Hub API Key">Electron Hub API Key</h4>
    +                            <div class="flex-container">
    +                                <input id="api_key_electronhub" name="api_key_electronhub" class="text_pole flex1" value="" type="text" autocomplete="off">
    +                                <div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_electronhub"></div>
    +                            </div>
    +                            <div data-for="api_key_electronhub" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
    +                                For privacy reasons, your API key will be hidden after you click 'Connect'.
    +                            </div>
    +                            <h4 data-i18n="Electron Hub Model">Electron Hub Model</h4>
    +                            <select id="model_electronhub_select">
    +                                <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
    +                            </select>
    +                        </div>
                             <div id="nanogpt_form" data-source="nanogpt">
                                 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
                                 <div class="flex-container">
    @@ -3718,6 +3736,49 @@ <h4 data-i18n="Moonshot AI Model">Moonshot AI Model</h4>
                                     <option value="kimi-thinking-preview">kimi-thinking-preview</option>
                                 </select>
                             </div>
    +                        <div id="azure_openai_settings" data-source="azure_openai">
    +                            <!-- Azure Base URL -->
    +                            <h4><span data-i18n="Azure Base URL">Azure Base URL</span></h4>
    +                            <div class="flex-container">
    +                                <input id="azure_base_url" data-setting="azure_base_url" class="text_pole wide100p" type="text" placeholder="https://your-resource.openai.azure.com/">
    +                            </div>
    +
    +                            <!-- Azure Deployment Name -->
    +                            <h4><span data-i18n="Deployment Name">Deployment Name</span></h4>
    +                            <div class="flex-container">
    +                                <input id="azure_deployment_name" data-setting="azure_deployment_name" class="text_pole wide100p" type="text" placeholder="your-deployment-name" title="The name of your model deployment in Azure." data-i18n="[title]The name of your model deployment in Azure.">
    +                            </div>
    +
    +                            <!-- Azure API Version Dropdown -->
    +                            <h4><span data-i18n="API Version">API Version</span></h4>
    +                            <div class="flex-container">
    +                                <select id="azure_api_version" data-setting="azure_api_version" class="text_pole wide100p">
    +                                    <option value="2025-04-01-preview">2025-04-01-preview</option>
    +                                    <option value="2024-10-21">2024-10-21</option>
    +                                </select>
    +                            </div>
    +
    +                            <!-- Azure API Key -->
    +                            <h4><span data-i18n="Azure API Key">Azure API Key</span></h4>
    +                            <div class="flex-container">
    +                                <input id="api_key_azure_openai" data-setting="api_key_azure_openai" class="text_pole flex1" type="password" autocomplete="off">
    +                                <div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_azure_openai"></div>
    +                            </div>
    +                            <div class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'." data-for="api_key_azure_openai">
    +                                For privacy reasons, your API key will be hidden after you click 'Connect'.
    +                            </div>
    +
    +                            <!-- Model Name (Select) -->
    +                            <h4><span data-i18n="Model Name">Model Name</span></h4>
    +                            <div class="flex-container">
    +                                <select id="azure_openai_model" data-setting="azure_openai_model" class="text_pole wide100p">
    +                                    <option value="" disabled selected data-i18n="Click 'Connect' to fetch model name">Click 'Connect' to fetch model name</option>
    +                                </select>
    +                            </div>
    +                            <div>
    +                                <small data-i18n="The underlying model of your deployment. This is detected automatically when you connect.">The underlying model of your deployment. This is detected automatically when you connect.</small>
    +                            </div>
    +                        </div>
                             <div id="prompt_post_processing_form">
                                 <h4>
                                     <span data-i18n="Prompt Post-Processing">
    @@ -5282,13 +5343,11 @@ <h4 data-i18n="STscript Settings">STscript Settings</h4>
                 <div id="site_logo" class="drawer-toggle drawer-header" title="Change Background Image" data-i18n="[title]Change Background Image">
                     <div class="drawer-icon fa-solid fa-panorama fa-fw closedIcon"></div>
                 </div>
    -            <div id="Backgrounds" class="drawer-content closedDrawer">
    -                <div class="flex-container">
    +            <div id="Backgrounds" class="drawer-content closedDrawer bg-drawer-layout">
    +                <div id="bg-header-fixed">
                         <div class="flex-container alignItemsBaseline wide100p">
    -                        <h3 class="margin0 flex2" data-i18n="Background Image">
    -                            Background Image
    -                        </h3>
    -                        <input id="bg-filter" data-i18n="[placeholder]Filter" placeholder="Filter" class="text_pole flex1" type="search" />
    +                        <h3 id="bg-header-title" class="margin0" data-i18n="Backgrounds">Backgrounds</h3>
    +                        <input id="bg-filter" class="text_pole flex1" type="search" data-i18n="[placeholder]Search" placeholder="Search" />
                             <select id="background_fitting" class="text_pole" data-i18n="[title]Background Fitting" title="Background Fitting">
                                 <option value="classic" data-i18n="Classic">Classic</option>
                                 <option value="cover" data-i18n="Cover">Cover</option>
    @@ -5300,17 +5359,17 @@ <h3 class="margin0 flex2" data-i18n="Background Image">
                                 <i class="fa-solid fa-wand-magic"></i>
                                 <span data-i18n="Auto-select">Auto-select</span>
                             </div>
    +                        <label for="add_bg_button" id="add_background_button_top" class="menu_button menu_button_icon interactable" title="Add a new background">
    +                            <i class="fa-solid fa-plus"></i>
    +                            <span data-i18n="Add Background">Add Background</span>
    +                        </label>
                         </div>
    +                </div>
    +                <div id="bg-scrollable-content">
                         <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter">
                             System Backgrounds
                         </h3>
                         <div id="bg_menu_content" class="bg_list">
    -                        <form id="form_bg_download" class="bg_example no-border no-shadow" action="javascript:void(null);" method="post" enctype="multipart/form-data">
    -                            <label class="input-file">
    -                                <input type="file" id="add_bg_button" name="avatar" accept="image/*, video/*">
    -                                <div class="bg_example no-border no-shadow add_bg_but" style="background-image: url('/img/addbg3.png');"></div>
    -                            </label>
    -                        </form>
                         </div>
                         <h3 data-i18n="Chat Backgrounds" class="wide100p textAlignCenter">
                             Chat Backgrounds
    @@ -5321,6 +5380,9 @@ <h3 data-i18n="Chat Backgrounds" class="wide100p textAlignCenter">
                         <div id="bg_custom_content" class="bg_list">
                         </div>
                     </div>
    +                <form id="form_bg_upload" style="display: none;">
    +                   <input type="file" id="add_bg_button" name="avatar" accept="image/jpeg,image/png,image/gif,image/bmp,image/svg+xml,video/*">
    +                </form>
                 </div>
             </div>
             <div id="extensions-settings-button" class="drawer">
    @@ -5410,7 +5472,7 @@ <h4 class="margin0">
                 <div class="drawer-toggle">
                     <div class="drawer-icon fa-solid fa-face-smile fa-fw closedIcon" title="Persona Management" data-i18n="[title]Persona Management"></div>
                 </div>
    -            <div class="drawer-content closedDrawer">
    +            <div id="PersonaManagement" class="drawer-content closedDrawer">
                     <div class="flex-container wide100p alignitemscenter spaceBetween flexNoGap">
                         <div class="flex-container alignItemsBaseline wide100p">
                             <div class="flex1 flex-container alignItemsBaseline">
    @@ -6254,14 +6316,20 @@ <h5>
                 </div>
             </div>
             <div id="background_template" class="template_element">
    -            <div class="bg_example flex-container" bgfile="" class="bg_example_img" title="">
    -                <div title="Copy to system backgrounds" data-i18n="[title]Copy to system backgrounds" class="bg_button bg_example_copy fa-solid fa-file-arrow-up"></div>
    -                <div title="Rename background" data-i18n="[title]Rename background" class="bg_button bg_example_edit fa-solid fa-pencil"></div>
    -                <div title="Lock" data-i18n="[title]Lock" class="bg_button bg_example_lock fa-solid fa-lock"></div>
    -                <div title="Unlock" data-i18n="[title]Unlock" class="bg_button bg_example_unlock fa-solid fa-lock-open"></div>
    -                <div title="Delete background" data-i18n="[title]Delete background" class="bg_button bg_example_cross fa-solid fa-circle-xmark"></div>
    -                <div class="BGSampleTitle">
    +            <div class="bg_example flex-container" bgfile="" title="">
    +                <div class="bg_example_img"></div>
    +                <div class="mobile-only-menu-toggle">
    +                    <i class="fa-solid fa-ellipsis-vertical"></i>
    +                </div>
    +                <div class="jg-menu">
    +                    <div data-action="copy" class="jg-button jg-copy fa-solid fa-file-arrow-up" data-i18n="[title]Copy to system backgrounds" title="Copy to system backgrounds"></div>
    +                    <!-- temporarily moved lock icon here (will be moved to header) -->
    +                    <div data-action="lock" class="jg-button jg-lock fa-solid fa-lock fa-fw pointer" data-i18n="[title]Lock" title="Lock"></div>
    +                    <div data-action="unlock" class="jg-button jg-unlock fa-solid fa-lock-open fa-fw pointer" data-i18n="[title]Unlock" title="Unlock"></div>
    +                    <div data-action="edit" class="jg-button jg-edit fa-solid fa-pen-to-square fa-fw pointer" data-i18n="[title]Rename Background" title="Rename Background"></div>
    +                    <div data-action="delete" class="jg-button jg-delete fa-solid fa-trash-can fa-fw pointer" data-i18n="[title]Delete Background" title="Delete Background"></div>
                     </div>
    +                <div class="BGSampleTitle"></div>
                 </div>
             </div>
             <!-- templates for JS to reuse when needed -->
    @@ -6437,7 +6505,7 @@ <h4>
                                             </span>
                                             <div class="flex-container">
                                                 <div class="flex-container flexFlowColumn">
    -                                                <label class="checkbox flex-container alignitemscenter flexNoGap" title="This entry will not be recursively activated by other entries.">
    +                                                <label class="checkbox flex-container alignitemscenter flexNoGap" data-i18n="[title]This entry will not be recursively activated by other entries." title="This entry will not be recursively activated by other entries.">
                                                         <input type="checkbox" name="excludeRecursion" />
                                                         <span data-i18n="Non-recursable">
                                                             Non-recursable
    
  • public/locales/ar-sa.json+2 0 modified
    @@ -317,6 +317,8 @@
         "flag": "وضع علامة",
         "API key (optional)": "مفتاح API (اختياري)",
         "Server url": "رابط الخادم",
    +    "Electron Hub API Key": "مفتاح API لـ Electron Hub",
    +    "Electron Hub Model": "نموذج Electron Hub",
         "Example: http://127.0.0.1:5000": "مثال: http://127.0.0.1:5000",
         "Custom model (optional)": "نموذج مخصص (اختياري)",
         "vllm-project/vllm": "vllm-project/vllm (وضع غلاف OpenAI API)",
    
  • public/locales/de-de.json+2 0 modified
    @@ -317,6 +317,8 @@
         "flag": "Flagge",
         "API key (optional)": "API-Schlüssel (optional)",
         "Server url": "Server-URL",
    +    "Electron Hub API Key": "Electron Hub API-Schlüssel",
    +    "Electron Hub Model": "Electron Hub-Modell",
         "Example: http://127.0.0.1:5000": "Beispiel: http://127.0.0.1:5000",
         "Custom model (optional)": "Benutzerdefiniertes Modell (optional)",
         "vllm-project/vllm": "vllm-project/vllm (OpenAI API-Wrappermodus)",
    
  • public/locales/es-es.json+2 0 modified
    @@ -317,6 +317,8 @@
         "flag": "bandera",
         "API key (optional)": "Clave API (opcional)",
         "Server url": "URL del servidor",
    +    "Electron Hub API Key": "Clave API de Electron Hub",
    +    "Electron Hub Model": "Modelo de Electron Hub",
         "Example: http://127.0.0.1:5000": "Ejemplo: http://127.0.0.1:5000",
         "Custom model (optional)": "Modelo personalizado (opcional)",
         "vllm-project/vllm": "vllm-project/vllm (modo contenedor de API OpenAI)",
    
  • public/locales/fr-fr.json+2 0 modified
    @@ -1411,6 +1411,8 @@
         "Do not proceed if you do not agree to this!": "Ne continuez pas si vous n'êtes pas d'accord avec cela !",
         "Claude API Key": "Clé API Claude",
         "Allow fallback models": "Autoriser les modèles de secours",
    +    "Electron Hub API Key": "Clé API Electron Hub",
    +    "Electron Hub Model": "Modèle Electron Hub",
         "NanoGPT API Key": "Clé API NanoGPT",
         "NanoGPT Model": "Modèle NanoGPT",
         "DeepSeek API Key": "Clé API DeepSeek",
    
  • public/locales/it-it.json+2 0 modified
    @@ -317,6 +317,8 @@
         "flag": "bandiera",
         "API key (optional)": "Chiave API (opzionale)",
         "Server url": "URL del server",
    +    "Electron Hub API Key": "Chiave API di Electron Hub",
    +    "Electron Hub Model": "Modello di Electron Hub",
         "Example: http://127.0.0.1:5000": "Esempio: http://127.0.0.1:5000",
         "Custom model (optional)": "Modello personalizzato (opzionale)",
         "vllm-project/vllm": "vllm-project/vllm (modalità wrapper API OpenAI)",
    
  • public/locales/ja-jp.json+2 0 modified
    @@ -317,6 +317,8 @@
         "flag": "フラグ",
         "API key (optional)": "APIキー(オプション)",
         "Server url": "サーバーURL",
    +    "Electron Hub API Key": "Electron Hub API キー",
    +    "Electron Hub Model": "Electron Hub モデル",
         "Example: http://127.0.0.1:5000": "例: http://127.0.0.1:5000",
         "Custom model (optional)": "カスタムモデル(オプション)",
         "vllm-project/vllm": "vllm-project/vllm (OpenAI API ラッパーモード)",
    
  • public/locales/ko-kr.json+2 0 modified
    @@ -319,6 +319,8 @@
         "flag": "깃발",
         "API key (optional)": "API 키 (선택 사항)",
         "Server url": "서버 URL",
    +    "Electron Hub API Key": "Electron Hub API 키",
    +    "Electron Hub Model": "Electron Hub 모델",
         "Example: http://127.0.0.1:5000": "예시: http://127.0.0.1:5000",
         "Custom model (optional)": "사용자 정의 모델 (선택 사항)",
         "vllm-project/vllm": "vllm-project/vllm(OpenAI API 래퍼 모드)",
    
  • public/locales/lang.json+66 17 modified
    @@ -1,17 +1,66 @@
    -[
    
    -    { "lang": "ar-sa",  "display": "عربي (Arabic)" },
    
    -    { "lang": "zh-cn",  "display": "简体中文 (Chinese) (Simplified)" },
    
    -    { "lang": "zh-tw",  "display": "繁體中文 (Chinese) (Taiwan)" },
    
    -    { "lang": "nl-nl",  "display": "Nederlands (Dutch)" },
    
    -    { "lang": "de-de",  "display": "Deutsch (German)" },
    
    -    { "lang": "fr-fr",  "display": "Français (French)" },
    
    -    { "lang": "is-is",  "display": "íslenska (Icelandic)" },
    
    -    { "lang": "it-it",  "display": "Italiano (Italian)" },
    
    -    { "lang": "ja-jp",  "display": "日本語 (Japanese)" },
    
    -    { "lang": "ko-kr",  "display": "한국어 (Korean)" },
    
    -    { "lang": "pt-pt",  "display": "Português (Portuguese brazil)" },
    
    -    { "lang": "ru-ru",  "display": "Русский (Russian)" },
    
    -    { "lang": "es-es",  "display": "Español (Spanish)" },
    
    -    { "lang": "uk-ua",  "display": "Yкраїнська (Ukrainian)" },
    
    -    { "lang": "vi-vn",  "display": "Tiếng Việt (Vietnamese)" }
    
    -]
    \ No newline at end of file
    +[
    +    {
    +        "lang": "ar-sa",
    +        "display": "عربي (Arabic)"
    +    },
    +    {
    +        "lang": "zh-cn",
    +        "display": "简体中文 (Chinese) (Simplified)"
    +    },
    +    {
    +        "lang": "zh-tw",
    +        "display": "繁體中文 (Chinese) (Taiwan)"
    +    },
    +    {
    +        "lang": "nl-nl",
    +        "display": "Nederlands (Dutch)"
    +    },
    +    {
    +        "lang": "de-de",
    +        "display": "Deutsch (German)"
    +    },
    +    {
    +        "lang": "fr-fr",
    +        "display": "Français (French)"
    +    },
    +    {
    +        "lang": "is-is",
    +        "display": "íslenska (Icelandic)"
    +    },
    +    {
    +        "lang": "it-it",
    +        "display": "Italiano (Italian)"
    +    },
    +    {
    +        "lang": "ja-jp",
    +        "display": "日本語 (Japanese)"
    +    },
    +    {
    +        "lang": "ko-kr",
    +        "display": "한국어 (Korean)"
    +    },
    +    {
    +        "lang": "pt-pt",
    +        "display": "Português (Portuguese brazil)"
    +    },
    +    {
    +        "lang": "ru-ru",
    +        "display": "Русский (Russian)"
    +    },
    +    {
    +        "lang": "es-es",
    +        "display": "Español (Spanish)"
    +    },
    +    {
    +        "lang": "uk-ua",
    +        "display": "Українська (Ukrainian)"
    +    },
    +    {
    +        "lang": "vi-vn",
    +        "display": "Tiếng Việt (Vietnamese)"
    +    },
    +    {
    +        "lang": "th-th",
    +        "display": "ไทย (Thai)"
    +    }
    +]
    
  • public/locales/pt-pt.json+2 0 modified
    @@ -317,6 +317,8 @@
         "flag": "bandeira",
         "API key (optional)": "Chave da API (opcional)",
         "Server url": "URL do servidor",
    +    "Electron Hub API Key": "Chave API Electron Hub",
    +    "Electron Hub Model": "Modelo Electron Hub",
         "Example: http://127.0.0.1:5000": "Exemplo: http://127.0.0.1:5000",
         "Custom model (optional)": "Modelo personalizado (opcional)",
         "vllm-project/vllm": "vllm-project/vllm (modo wrapper da API OpenAI)",
    
  • public/locales/ru-ru.json+153 84 modified
    @@ -40,7 +40,7 @@
         "Smoothing Factor": "Коэффициент сглаживания",
         "No Repeat Ngram Size": "Размер no_repeat_ngram",
         "Min Length": "Мин. длина",
    -    "Alternative server URL (leave empty to use the default value).": "URL альтернативного сервера (оставьте пустым для стандартного значения)",
    +    "Alternative server URL (leave empty to use the default value).": "URL реверс-прокси (оставьте пустым для стандартного значения)",
         "Remove your real OAI API Key from the API panel BEFORE typing anything into this box": "Удалите свой личный OAI API Key из панели API, и ТОЛЬКО ПОСЛЕ ЭТОГО вводите что-то сюда",
         "We cannot provide support for problems encountered while using an unofficial OpenAI proxy": "Мы не сможем предоставить помощь с проблемами, с которыми вы столкнетесь при использовании неофициальных прокси для OpenAI",
         "Context Size (tokens)": "Размер контекста (в токенах)",
    @@ -145,7 +145,7 @@
         "View API Usage Metrics": "Посмотреть статистику использования API",
         "Show External models (provided by API)": "Показать \"сторонние\" модели (предоставленные API)",
         "Allow fallback routes": "Разрешить резервные маршруты",
    -    "Allow fallback routes Description": "Автоматически выбирает альтернативную модель, если выбранная модель не может удовлетворить ваш запрос.",
    +    "Allow fallback routes Description": "Автоматически выбирает альтернативную модель, если выбранная модель не может обслужить ваш запрос.",
         "OpenRouter API Key": "Ключ от OpenRouter API",
         "OpenRouter Model": "Модель OpenRouter",
         "View Remaining Credits": "Посмотреть оставшиеся кредиты",
    @@ -154,7 +154,7 @@
         "View hidden API keys": "Посмотреть скрытые API-ключи",
         "Advanced Formatting": "Расширенное форматирование",
         "Context Template": "Шаблон контекста",
    -    "Replace Macro in Stop Strings": "Заменять макросы в пользовательских стоп-строках",
    +    "Replace Macro in Stop Strings": "Заменять макросы в стоп-строках",
         "Story String": "Общий шаблон",
         "Example Separator": "Разделитель примеров сообщений",
         "Chat Start": "Начало чата",
    @@ -184,7 +184,7 @@
         "Scan Depth": "Глубина сканирования",
         "Case-Sensitive": "С учетом регистра",
         "Match Whole Words": "Только полное совпадение",
    -    "Use global setting": "Использовать глобальную настройку",
    +    "Use global setting": "Глоб. настройка",
         "Yes": "Да",
         "No": "Нет",
         "Context %": "Процент контекста",
    @@ -201,7 +201,7 @@
         "Bubbles": "Пузыри",
         "No Blur Effect": "Отключить размытие",
         "No Text Shadows": "Отключить тень текста",
    -    "Waifu Mode": "Рeжим Вайфу",
    +    "Waifu Mode": "Режим вайфу",
         "Message Timer": "Таймер сообщений",
         "Model Icon": "Значки моделей",
         "Advanced Character Search": "Расширенный поиск по персонажам",
    @@ -279,7 +279,7 @@
         "Impersonate": "Перевоплощение",
         "Regenerate": "Повторная генерация",
         "Message Sound": "Звук сообщения",
    -    "Author's Note": "Заметки автора",
    +    "Author's Note": "Авторские заметки",
         "Replace empty message": "Заменять пустые сообщения",
         "Send this text instead of nothing when the text box is empty.": "Этот текст будет отправлен в случае отсутствия текста на отправку.",
         "Unrestricted maximum value for the context slider": "Убрать потолок для ползунка контекста. Включайте только если точно понимаете, что делаете",
    @@ -322,8 +322,8 @@
         "Order ↘": "Порядок ↘",
         "UID ↗": "UID ↗",
         "UID ↘": "UID ↘",
    -    "Trigger% ↗": "Триггер% ↗",
    -    "Trigger% ↘": "Триггер% ↘",
    +    "Trigger% ↗": "% срабатываний ↗",
    +    "Trigger% ↘": "% срабатываний ↘",
         "Depth:": "Глубина:",
         "Character Lore First": "Сначала лор персонажа",
         "Global Lore First": "Сначала глобальный лор",
    @@ -334,12 +334,17 @@
         "Exclude from recursion": "Исключить из рекурсии",
         "Entry Title/Memo": "Название или заметка о записи",
         "Position:": "Положение:",
    -    "T_Position": "↑Char: Перед определениями Персонажа\n↓Char: После определений Персонажа\n↑AN: Перед Пометок автора\n↓AN: После Пометок автора\n@D: На глубине",
    +    "T_Position": "↑Перс: Перед описанием персонажа\n↓Перс: После описания персонажа\n↑ПС: Перед примерами сообщений\n↓ПС: После примеров сообщений\n↑АЗ: Перед авторскими заметками\n↓АЗ: После авторских заметок\nНа глуб. ⚙️: на глубине (система)\nНа глуб. 👤: на глубине (пользователь)\nНа глуб. 🤖: на глубине (ассистент)",
         "Before Char Defs": "↑Перс.",
         "After Char Defs": "↓Перс.",
         "Before AN": "↑АЗ",
         "After AN": "↓АЗ",
    -    "Order": "Очерёдность:",
    +    "Before EM": "↑ПС",
    +    "After EM": "↓ПС",
    +    "at Depth System": "На глуб. ⚙️",
    +    "at Depth User": "На глуб. 👤",
    +    "at Depth AI": "На глуб. 🤖",
    +    "Order": "Приоритет:",
         "Update a theme file": "Обновить файл темы",
         "Save as a new theme": "Сохранить как новую тему",
         "Minimum number of blacklisted words detected to trigger an auto-swipe": "Минимальное количество обнаруженных запрещённых слов, при котором срабатывает авто-свайп.",
    @@ -385,7 +390,7 @@
         "Remove text shadow effect": "Удаление эффекта тени от текста.",
         "Reduce chat height, and put a static sprite behind the chat window": "Уменьшить высоту чата и поместить статичный спрайт за окном чата.",
         "Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "Всегда показывать полный список действий с сообщением, а не прятать их за '...'.",
    -    "Alternative UI for numeric sampling parameters with fewer steps": "Альтернативный пользовательский интерфейс для числовых параметров выборки с меньшим количеством шагов.",
    +    "Alternative UI for numeric sampling parameters with fewer steps": "Уменьшить кол-во шагов для параметров, регулируемых слайдерами.",
         "Entirely unrestrict all numeric sampling parameters": "Снять ограничения со всех числовых сэмплеров.",
         "Time the AI's message generation, and show the duration in the chat log": "Время генерации сообщений ИИ и его показ в журнале чата.",
         "Show a timestamp for each message in the chat log": "Показывать временную метку для каждого сообщения в журнале чата.",
    @@ -409,7 +414,7 @@
         "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "При включении этой опции, пользовательский джейлбрейк будет заменяться кастомным джейлбрейком из карточки (при его наличии).",
         "Show actual file names on the disk, in the characters list display only": "Отображение названий файлов персонажей на диске, только в списке персонажей.",
         "Prompt to import embedded card tags on character import. Otherwise embedded tags are ignored": "Запрашивать разрешения на импорт встроенных тегов карт при импорте персонажей. В противном случае встроенные теги игнорируются.",
    -    "Hide character definitions from the editor panel behind a spoiler button": "Спрятать определения персонажей из панели редактора за кнопку спойлера.",
    +    "Hide character definitions from the editor panel behind a spoiler button": "Спрятать описания персонажей из панели редактора за кнопку спойлера.",
         "Show a button in the input area to ask the AI to continue (extend) its last message": "Показывать на форме ответа кнопку, по нажатии на которую ИИ продолжит своё предыдущее сообщение.",
         "Show arrow buttons on the last in-chat message to generate alternative AI responses. Both PC and mobile": "Показывать кнопки со стрелками на последнем сообщении в чате, чтобы генерировать альтернативные ответы ИИ. Как для ПК, так и для мобильных устройств.",
         "Allow using swiping gestures on the last in-chat message to trigger swipe generation. Mobile only, no effect on PC": "Позволяет использовать жесты смахивания на последнем сообщении в чате, чтобы вызвать альтернативную генерацию. Только для мобильных устройств, на ПК не работает.",
    @@ -427,16 +432,16 @@
         "Your Persona": "Ваша персона",
         "Show notifications on switching personas": "Показывать уведомления при смене персоны",
         "In Story String / Prompt Manager": "В общем шаблоне / Менеджере промптов",
    -    "Top of Author's Note": "Сверху от заметок автора",
    -    "Bottom of Author's Note": "Снизу от заметок автора",
    +    "Top of Author's Note": "Сверху от авторских заметок",
    +    "Bottom of Author's Note": "Снизу от авторских заметок",
         "How do I use this?": "Как пользоваться?",
         "More...": "Ещё...",
         "Link to World Info": "Ссылка на информацию о мире",
         "Import Card Lore": "Импортировать лор карточки",
         "Scenario Override": "Перезапись сценария",
         "Rename": "Переименовать",
         "Character Description": "Описание персонажа",
    -    "Creator's Notes": "Заметки создателя",
    +    "Creator's Notes": "Примечание от создателя",
         "A-Z": "A-Z",
         "Z-A": "Z-A",
         "Newest": "Сначала новые",
    @@ -471,7 +476,7 @@
         "Enter your name": "Введите свое имя",
         "Name this character": "Назовите этого персонажа",
         "Search / Create Tags": "Искать / Создать тэги",
    -    "Describe your character's physical and mental traits here.": "Опишите ментальные и физические черты персонажа",
    +    "Describe your character's physical and mental traits here.": "Опишите характер персонажа и его внешность",
         "This will be the first message from the character that starts every chat.": "Это будет первое сообщение от персонажа при начале нового чата",
         "Chat Name (Optional)": "Название чата (необязательно)",
         "Search...": "Поиск...",
    @@ -483,10 +488,10 @@
         "(Write a comma-separated list of tags)": "(Список тегов через запятую)",
         "(A brief description of the personality)": "(Краткое описание личности)",
         "(Circumstances and context of the interaction)": "(Обстоятельства и контекст этого диалога)",
    -    "(Examples of chat dialog. Begin each example with START on a new line.)": "(Примеры диалога. Начинайте каждый пример с START или новой строкой.)",
    +    "(Examples of chat dialog. Begin each example with START on a new line.)": "(Примеры диалога. Начинайте каждый пример со START и на новой строке.)",
         "Type here...": "Пишите здесь...",
         "Comma separated (required)": "Через запятую (обязательное поле)",
    -    "What this keyword should mean to the AI, sent verbatim": "Что это ключевое слово должно означать для ИИ, отправляется дословно",
    +    "What this keyword should mean to the AI, sent verbatim": "Объясните ИИ, что он должен знать об этом ключевом слове",
         "Filter to Character(s)": "Фильтр по персонажу(ам)",
         "Character Exclusion": "Исключить персонажей",
         "Inclusion Group": "Группа записей",
    @@ -569,12 +574,12 @@
         "Remove": "Убрать",
         "Select a World Info file for": "Выбрать файл с миром для",
         "Primary Lorebook": "Основной лорбук",
    -    "A selected World Info will be bound to this character as its own Lorebook.": "Информация о мире будет привязана к персонажу как его собственный лорбук.",
    -    "When generating an AI reply, it will be combined with the entries from a global World Info selector.": "Когда ИИ генерирует ответ, он будет совмещён с записями из глобально выбранного мира.",
    +    "A selected World Info will be bound to this character as its own Lorebook.": "Данный мир будет привязан к персонажу как его собственный лорбук.",
    +    "When generating an AI reply, it will be combined with the entries from a global World Info selector.": "При генерации ответа, данный лорбук будет работать вместе с глобально выбранным лорбуком.",
         "Exporting a character would also export the selected Lorebook file embedded in the JSON data.": "При экспорте персонажа вместе с ним также выгрузится выбранный лорбук в виде JSON.",
         "Additional Lorebooks": "Вспомогательные лорбуки",
    -    "Associate one or more auxillary Lorebooks with this character.": "Привязать к этому персонажу один или больше вспомогательных лорбуков",
    -    "NOTE: These choices are optional and won't be preserved on character export!": "ВНИМАНИЕ: эти выборы необязательные и не будут сохранены при экспорте персонажа!",
    +    "Associate one or more auxillary Lorebooks with this character.": "Привязать к этому персонажу один или больше вспомогательных лорбуков.",
    +    "NOTE: These choices are optional and won't be preserved on character export!": "ВНИМАНИЕ: вспомогательные лорбуки не будут выгружены при экспорте персонажа!",
         "Rename chat file": "Переименовать чат",
         "Export JSONL chat file": "Экспортировать чат в формате JSONL",
         "Download chat as plain text document": "Скачать чат в формате .txt",
    @@ -784,20 +789,20 @@
         "Enable magnification for zoomed avatar display.": "Добавляет возможность приближать увеличенную версию аватарки.",
         "Unique to this chat": "Только для текущего чата",
         "Checkpoints inherit the Note from their parent, and can be changed individually after that.": "Чекпоинты наследуют заметки от родительского чата, но впоследствие их всегда можно изменить.",
    -    "Include in World Info Scanning": "Учитывать при сканировании Информации о мире",
    -    "Before Main Prompt / Story String": "Перед основным промптом / строкой истории",
    -    "After Main Prompt / Story String": "После основного промпта / строки истории",
    +    "Include in World Info Scanning": "Учитывать при сканировании лорбуком",
    +    "Before Main Prompt / Story String": "Перед основным промптом / общим шаблоном",
    +    "After Main Prompt / Story String": "После основного промпта / общего шаблона",
         "In-chat @ Depth": "Встав. на глуб.",
         "as": "роль:",
         "Insertion Frequency": "Частота вставки",
         "(0 = Disable, 1 = Always)": "(0 = никогда, 1 = всегда)",
         "User inputs until next insertion:": "Ваших сообщений до след. вставки:",
    -    "Character Author's Note (Private)": "Заметки автора персонажа (личные)",
    -    "Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.": "Автоматически применятся к этому персонажу в качестве заметок автора. Будут использоваться в группах, но при активном групповом чате к редактированию недоступны.",
    -    "Use character author's note": "Использовать заметки автора персонажа",
    -    "Replace Author's Note": "Вместо заметок автора",
    -    "Default Author's Note": "Стандартные заметки автора",
    -    "Will be automatically added as the Author's Note for all new chats.": "Будут автоматически добавляться во все новые чаты в качестве Заметок автора",
    +    "Character Author's Note (Private)": "Авторские заметки для персонажа (личные)",
    +    "Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.": "Автоматически применятся к этому персонажу в качестве авторских заметок. Будут использоваться в группах, но при активном групповом чате к редактированию недоступны.",
    +    "Use character author's note": "Использовать авторские заметки для персонажа",
    +    "Replace Author's Note": "Вместо авторских заметок",
    +    "Default Author's Note": "Стандартные авторские заметки",
    +    "Will be automatically added as the Author's Note for all new chats.": "Будут автоматически добавляться во все новые чаты в качестве авторских заметок",
         "1 = disabled": "1 = откл.",
         "write short replies, write replies using past tense": "пиши короткие ответы, пиши в настоящем времени",
         "Positive Prompt": "Положительный промпт",
    @@ -906,7 +911,7 @@
         "ext_sum_force_text": "Пересказать сейчас",
         "Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API).": "Отключить авто-обновление пересказа. Пересказ всё время будет фиксированным. Однако останется возможность принудительно обновить пересказ через кнопку \"Пересказать сейчас\" (доступно только через Основное API)",
         "ext_sum_pause": "Приостановить",
    -    "Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN.": "Исключать из пересказа Информацию о мире и Заметки автора. Работает только для Основного API. Extras API всегда их исключает.",
    +    "Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN.": "Исключать из пересказа Информацию о мире и Авторские заметки. Работает только для Основного API. Extras API всегда их исключает.",
         "ext_sum_no_wi_an": "Без мира и заметок",
         "ext_sum_settings_tip": "Изменить промпт пересказа, место для инжекта и т.д.",
         "ext_sum_settings": "Настройки пересказа",
    @@ -1333,13 +1338,7 @@
         "WI_Entry_Status_Normal": "Обычная",
         "WI_Entry_Status_Vectorized": "Векторизованная",
         "WI_Entry_Status_Disabled": "Отключена",
    -    "Before EM": "↑EM",
    -    "After EM": "↓EM",
    -    "at Depth System": "@D ⚙️",
    -    "at Depth User": "@D 👤",
    -    "at Depth AI": "@D 🤖",
         "Depth": "Глубина",
    -    "Trigger %:": "Trigger %:",
         "Probability": "Вероятность",
         "Duplicate world info entry": "Дублировать запись",
         "Delete world info entry": "Удалить запись",
    @@ -1411,26 +1410,7 @@
         "ext_regex_title": "Regex",
         "ext_regex_import_target": "Импортировать в:",
         "ext_regex_move_to_scoped": "Сделать локальным",
    -    "Trigger Stable Diffusion": "Trigger Stable Diffusion",
    -    "sd_Yourself": "Yourself",
    -    "sd_Your_Face": "Your Face",
    -    "sd_Me": "Me",
    -    "sd_The_Whole_Story": "The Whole Story",
    -    "sd_The_Last_Message": "The Last Message",
    -    "sd_Raw_Last_Message": "Raw Last Message",
    -    "sd_Background": "Background",
         "Image Generation": "Image Generation",
    -    "sd_refine_mode": "Allow to edit prompts manually before sending them to generation API",
    -    "sd_refine_mode_txt": "Edit prompts before generation",
    -    "sd_interactive_mode": "Automatically generate images when sending messages like 'send me a picture of cat'.",
    -    "sd_interactive_mode_txt": "Interactive mode",
    -    "sd_multimodal_captioning": "Use multimodal captioning to generate prompts for user and character portraits based on their avatars.",
    -    "sd_multimodal_captioning_txt": "Use multimodal captioning for portraits",
    -    "sd_expand": "Automatically extend prompts using text generation model",
    -    "sd_expand_txt": "Auto-enhance prompts",
    -    "sd_snap": "Snap generation requests with a forced aspect ratio (portraits, backgrounds) to the nearest known resolution, while trying to preserve the absolute pixel counts (recommended for SDXL).",
    -    "sd_snap_txt": "Snap auto-adjusted resolutions",
    -    "Source": "Source",
         "sd_auto_url": "Example: {{auto_url}}",
         "Authentication (optional)": "Authentication (optional)",
         "Example: username:password": "Example: username:password",
    @@ -1441,28 +1421,17 @@
         "sd_drawthings_auth_txt": "run DrawThings app with HTTP API switch enabled in the UI! The server must be accessible from the SillyTavern host machine.",
         "sd_vlad_url": "Example: {{vlad_url}}",
         "The server must be accessible from the SillyTavern host machine.": "The server must be accessible from the SillyTavern host machine.",
    -    "Hint: Save an API key in AI Horde API settings to use it here.": "Hint: Save an API key in AI Horde API settings to use it here.",
         "Allow NSFW images from Horde": "Разрешить NSFW-картинки в Horde",
    -    "Sanitize prompts (recommended)": "Sanitize prompts (recommended)",
    -    "Automatically adjust generation parameters to ensure free image generations.": "Automatically adjust generation parameters to ensure free image generations.",
         "Avoid spending Anlas": "Avoid spending Anlas",
         "Opus tier": "(Opus tier)",
         "View my Anlas": "View my Anlas",
    -    "These settings only apply to DALL-E 3": "These settings only apply to DALL-E 3",
    -    "Image Style": "Image Style",
    -    "Image Quality": "Image Quality",
    -    "Standard": "Standard",
    -    "HD": "HD",
         "sd_comfy_url": "Example: {{comfy_url}}",
         "Open workflow editor": "Open workflow editor",
         "Create new workflow": "Create new workflow",
         "Delete workflow": "Delete workflow",
         "Enhance": "Enhance",
         "Refine": "Refine",
         "Decrisper": "Decrisper",
    -    "Sampling steps": "Sampling steps ()",
    -    "Width": "Width ()",
    -    "Height": "Height ()",
         "Resolution": "Resolution",
         "Model": "Model",
         "Sampling method": "Sampling method",
    @@ -1472,8 +1441,6 @@
         "DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.": "DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.",
         "DYN": "DYN",
         "Scheduler": "Scheduler",
    -    "Restore Faces": "Restore Faces",
    -    "Hires. Fix": "Hires. Fix",
         "Upscaler": "Upscaler",
         "Upscale by": "Upscale by",
         "Denoising strength": "Denoising strength",
    @@ -1484,7 +1451,6 @@
         "Delete style": "Delete style",
         "Common prompt prefix": "Common prompt prefix",
         "sd_prompt_prefix_placeholder": "Use {prompt} to specify where the generated prompt will be inserted",
    -    "Negative common prompt prefix": "Negative common prompt prefix",
         "Character-specific prompt prefix": "Character-specific prompt prefix",
         "Won't be used in groups.": "Won't be used in groups.",
         "sd_character_prompt_placeholder": "Any characteristics that describe the currently selected character. Will be added after a common prompt prefix.\nExample: female, green eyes, brown hair, pink shirt",
    @@ -1836,13 +1802,13 @@
         "Also delete the chat files": "Также удалить файлы чатов",
         "Delete the character?": "Удалить персонажа?",
         "Not a valid number": "Некорректное число",
    -    "Author's Note depth updated": "Глубина заметок автора обновлена",
    -    "Author's Note frequency updated": "Частота заметок автора обновлена",
    +    "Author's Note depth updated": "Глубина авторских заметок обновлена",
    +    "Author's Note frequency updated": "Частота авторских заметок обновлена",
         "Not a valid position": "Некорректная позиция",
    -    "Author's Note position updated": "Позиция заметок автора обновлена",
    -    "Something went wrong. Could not save character's author's note.": "Что-то пошло не так. Не удалось сохранить заметки автора для этого персонажа.",
    +    "Author's Note position updated": "Позиция авторских заметок обновлена",
    +    "Something went wrong. Could not save character's author's note.": "Что-то пошло не так. Не удалось сохранить авторские заметки для этого персонажа.",
         "Select a character before trying to use Author's Note": "Сначала необходимо выбрать персонажа",
    -    "Author's Note text updated": "Текст заметок автора обновлён",
    +    "Author's Note text updated": "Текст авторских заметок обновлён",
         "Group Validation": "Валидация группы",
         "Warning: Listed member ${0} does not exist as a character. It will be removed from the group.": "Предупреждение: персонаж ${0} не существует в виде карточки. Он будет удалён из группы.",
         "Group Chat could not be saved": "Не удалось сохранить групповой чат",
    @@ -1938,7 +1904,7 @@
         "Do you want to remove these fields before exporting?": "Желаете ли удалить эти поля перед экспортом?",
         "Save": "Сохранить",
         "Chat Lorebook": "Лорбук для чата",
    -    "chat_world_template_txt": "Выбранный мир будет привязан к этому чату. Будет добавляться в промпт наряду с глобальным лорбуком и лором персонажа.",
    +    "chat_world_template_txt": "Выбранный мир будет привязан к этому чату. Будет работать наряду с глобальным лорбуком и лором персонажа.",
         "world_button_title": "Лор персонажа\n\nНажмите, чтобы загрузить\nShift + ЛКМ, чтобы открыть диалог привязки мира",
         "No auxillary Lorebooks set. Click here to select.": "Вспомогательный лорбук не выбран. Нажмите, чтобы выбрать.",
         "ext_regex_user_input_desc": "Отправленные вами сообщения.",
    @@ -2010,7 +1976,7 @@
         "Imported tags:": "Импортируемые теги:",
         "Importing Tags": "Импорт тегов",
         "Couldn't import tags:": "Не удалось импортировать теги:",
    -    "Allow fallback models": "Разрешить fallback-модели",
    +    "Allow fallback models": "Разрешить резервные модели",
         "Allow fallback providers": "Разрешить fallback-провайдеров",
         "To use instruct formatting, switch to OpenRouter under Text Completion API.": "Переключитесь на OpenRouter в Text Completion API, чтобы использовать форматирование Instruct-режима.",
         "Select providers. No selection = all providers.": "Выберите провайдера. Нет выбранного = выбраны все.",
    @@ -2028,6 +1994,8 @@
         "Click on the setting name to omit it from the profile.": "Нажмите на название настройки, чтобы исключить её из профиля",
         "Included settings:": "Сохранённые параметры:",
         "Server URL": "Адрес сервера",
    +    "Electron Hub API Key": "Ключ от API Electron Hub",
    +    "Electron Hub Model": "Модель Electron Hub",
         "NanoGPT API Key": "Ключ от API NanoGPT",
         "NanoGPT Model": "Модель NanoGPT",
         "Use extension settings": "Использовать настройки из расширения",
    @@ -2044,14 +2012,14 @@
         "Title/Memo": "Название",
         "Strategy": "Статус",
         "Position": "Позиция",
    -    "Trigger %": "% срабатывания",
    +    "Trigger %": "% срабатываний",
         "Use global": "Глоб. настройка",
         "Whole Words": "Целые слова",
         "Non-recursable": "Не рекурсивная",
         "Delay until recursion": "Рекурсивная",
         "Toggle entry's active state.": "Вкл/выкл запись.",
         "Prioritize": "Важная",
    -    "Prioritize this entry: When checked, this entry is prioritized out of all selections.If multiple are prioritized, the one with the highest 'Order' is chosen.": "Важная запись получает приоритет среди всех выбранных. Если важных записей несколько, выбирается та, у которой выше \"Очерёдность\".",
    +    "Prioritize this entry: When checked, this entry is prioritized out of all selections.If multiple are prioritized, the one with the highest 'Order' is chosen.": "Важная запись получает приоритет среди всех выбранных. Если важных записей несколько, выбирается та, у которой выше \"Приоритет\".",
         "Group Weight": "Вес в группе",
         "A relative likelihood of entry activation within the group": "Относительная вероятность активации записи в рамках группы",
         "Sticky": "Липучка",
    @@ -2067,7 +2035,7 @@
         "Switch to plaintext mode": "Вкл/выкл режим чистого текста",
         "Exclude": "Режим исключения",
         "Switch the Character/Tags filter around to exclude the listed characters and tags from matching for this entry": "Инвертировать логику: для выбранных в фильтре персонажей/тегов данная запись активна НЕ БУДЕТ",
    -    "Apply current sorting as Order": "Настроить Очерёдность в соответствии с текущей сортировкой",
    +    "Apply current sorting as Order": "Настроить Приоритет в соответствии с текущей сортировкой",
         "Create a new World Info": "Создать новый мир",
         "Enter a name for the new file:": "Название нового файла:",
         "Inclusion Groups ensure only one entry from a group is activated at a time, if multiple are triggered.Documentation: World Info - Inclusion Group": "Если сразу несколько записей из одной группы окажутся активированными, по факту сработает только одна. Одна запись может входить в несколько групп, отделяются запятыми. Раздел в документации: World Info - Inclusion Group",
    @@ -2165,7 +2133,7 @@
         "openai_reasoning_effort_high": "Подробные",
         "Persona Lore Alt+Click to open the lorebook": "Лорбук данной персоны\nAlt + ЛКМ чтобы открыть лорбук",
         "Persona Lorebook for": "Лорбук для персоны",
    -    "persona_world_template_txt": "Выбранная Информация о мире будет привязана к этой персоне. Информация будет добавляться в каждом промпте вместе с глобальным лорбуком и лорбуками персонажа и чата.",
    +    "persona_world_template_txt": "Выбранный мир будет привязан к этой персоне. Будет работать вместе с глобальным лорбуком и лорбуками персонажа и чата.",
         "Global list": "Глобальный список",
         "Preset-specific list": "Список для данного пресета",
         "Banned tokens/strings are being sent in the request.": "Запрещённые токены и строки отсылаются в запросе.",
    @@ -2280,7 +2248,7 @@
         "Character Words": "Слов отправлено персонажем",
         "stats_header_User": "пользователю",
         "stats_header_Character": "персонажу",
    -    "${0} Stats": "Статистика по ${0}", 
    +    "${0} Stats": "Статистика по ${0}",
         "Context": "Контекст",
         "Response": "Ответ",
         "Connected": "Подключено",
    @@ -2521,6 +2489,16 @@
         "Automatically generated chat backups.": "Автоматически создаваемые бэкапы чатов.",
         "Settings Backups": "Копии настроек",
         "Automatically generated settings backups.": "Автоматически создаваемые бэкапы настроек.",
    +    "Files": "Файлы",
    +    "Files that are not associated with chat messages or Data Bank. WILL DELETE MANUAL UPLOADS!": "Файлы, не связанные ни с одним сообщением или банком данных. ЗАГРУЖЕННЫЕ ВРУЧНУЮ ФАЙЛЫ ТАКЖЕ БУДУТ УДАЛЕНЫ!",
    +    "Images": "Изображения",
    +    "Images that are not associated with chat messages. WILL DELETE MANUAL UPLOADS!": "Картинки, не связанные ни с одним сообщением. ЗАГРУЖЕННЫЕ ВРУЧНУЮ ФАЙЛЫ ТАКЖЕ БУДУТ УДАЛЕНЫ!",
    +    "Avatar Thumbnails": "Превью для аватарок",
    +    "Thumbnails for avatars of missing or deleted characters.": "Превью для аватарок удалённых персонажей",
    +    "Background Thumbnails": "Превью для фонов",
    +    "Thumbnails for missing or deleted backgrounds.": "Превью для удалённых фоновых изображений",
    +    "Persona Thumbnails": "Превью для персон",
    +    "Thumbnails for missing or deleted personas.": "Превью для удалённых персон",
         "Delete all items in this category": "Удалить всё в этой категории",
         "View item content": "Посмотреть содержимое",
         "Download item": "Скачать содержимое",
    @@ -2583,5 +2561,96 @@
         "Moonshot AI API Key": "Ключ от API Moonshot AI",
         "Moonshot AI Model": "Модель Moonshot AI",
         "AI/ML API Key": "Ключ от API AI/ML",
    -    "AI/ML Model": "Модель AI/ML"
    +    "AI/ML Model": "Модель AI/ML",
    +    "Replace Character": "Заменить персонажа",
    +    "Choose a new character card to replace this character with.": "Выберите, каким персонажем хотите заменить текущего.",
    +    "All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.": "Чаты с персонажем, его ассеты и членство в группах сохранятся. Однако сделанные вами изменения в карточке будут утеряны.",
    +    "Proceed?": "Продолжить?",
    +    "No Creator's Notes provided.": "Создатель не оставил примечаний.",
    +    "No auxiliary Lorebooks set. Click here to select.": "Вспомогательных лорбуков нет. ЛКМ, чтобы выбрать.",
    +    "Persona Title (optional, display only)": "Сноска (не влияет на чат, только в интерфейсе)",
    +    "This entry will not be recursively activated by other entries.": "Не будет активироваться другими записями.",
    +    "This entry will not activate other entries recursively.": "Не будет активировать другие записи.",
    +    "This entry can only be activated on recursive checking.": "Активируется только другими записями.",
    +    "This entry will be included ignoring budget constraints, assuming all other checks pass.": "Будет включена в промпт даже при превышении бюджета токенов (при условии, что все остальные условия соблюдены)",
    +    "Ignore budget": "Игнорировать бюджет",
    +    "Filter to Generation Triggers": "Только для опред. типов генераций",
    +    "Apply current sorting as Order": "Выставить приоритеты по текущей сортировке",
    +    "Apply your current sorting to the \"Order\" field. The Order values will go down from the chosen number.": "Заполнить поле \"Приоритет\" в соответствии с текущей сортировкой для всех записей. Поля будут заполнены значениями, начиная с этого числа:",
    +    "More than 100 entries in this world. If you don't choose a number higher than that, the lower entries will default to 0.<br />(Usual default: 100)<br />Minimum: ${0}": "В этом лорбуке больше 100 записей! Если не выставить число больше 100, то последние записи будут иметь приоритет 0.<br/>(Стандартное значение: 100)<br/>(Минимальное число: ${0})",
    +    "Apply": "Применить",
    +    "Apply Current Sorting": "Выставить приоритеты по сортировке",
    +    "Delete the entry with UID: ${0}?": "Удалить запись с UID: ${0}?",
    +    "This action is irreversible!": "Отменить будет невозможно!",
    +    "Recursion Level": "Уровень рекурсии",
    +    "delay_until_recursion_level": "Насколько глубоко должна зайти рекурсия, чтобы иметь возможность активировать эту запись.\n\nИзначально рекурсия делает проход по 1 уровню. Если там не находится подходящих записей, то она идёт на 2 уровень, и там повторяет процесс.\nИ так, пока не дойдёт до самого глубокого уровня.\n\nАктуально только для рекурсивных записей (с отмеченной галочкой \"Рекурсивная\").",
    +    "A number lower than the entry count has been chosen. All entries below that will default to 0.": "Введённое число меньше, чем общее кол-во записей. Всем записям внизу списка присвоен приоритет 0.",
    +    "Invalid number: ${0}": "Некорректное число: ${0}",
    +    "Allow animations for WEBP backgrounds. This is only a change for the selection menu.": "Разрешить анимации в WEBP фонах. Актуально только для меню выбора.",
    +    "sd_refine_mode_txt": "Редактировать промпты перед генерацией",
    +    "sd_refine_mode": "Отправлять промпты на проверку вам, прежде чем пересылать в API",
    +    "sd_function_tool_txt": "Использовать вызов функций",
    +    "sd_function_tool": "Использовать функцию, чтобы понимать, когда пора генерировать изображение",
    +    "sd_interactive_mode_txt": "Интерактивный режим",
    +    "sd_interactive_mode": "Автоматически генерировать изображения, когда появляется сообщение вида \"send me a picture of a cat\"",
    +    "sd_multimodal_captioning_txt": "Мультимодальный промптинг",
    +    "sd_multimodal_captioning": "Использовать мультимодальный промптинг для создания портретов пользователя и персонажа, основываясь на их аватарках",
    +    "sd_free_extend_txt": "Дописывать промпты в свободном режиме",
    +    "sd_free_extend_small": "(для интерактивного режима или команд)",
    +    "sd_free_extend": "Автоматически дописывать промпты в свободном режиме (для всего, что не касается фонов и портретов), используя текущую выбранную LLM",
    +    "sd_snap_txt": "Корректировать автоматически выбранное разрешение",
    +    "sd_snap": "Подгонять картинки с фиксированным соотношением сторон (фоны, портреты) к ближайшему известному разрешению (рекомендуется для SDXL)",
    +    "Source": "API",
    +    "Hint: Save an API key in AI Horde API settings to use it here.": "Подсказка: сохраните ключ от API в настройках API на сайте AI Horde, чтобы использовать его автоматически.",
    +    "Sanitize prompts (recommended)": "Прогонять промпты через санитайзер (рекомендуется)",
    +    "Sampling method": "Метод сэмплинга",
    +    "Resolution": "Целевое разрешение",
    +    "Sampling steps": "Кол-во шагов",
    +    "Width": "Ширина",
    +    "Height": "Высота",
    +    "Not all samplers supported.": "Поддерживает не все виды сэмплинга.",
    +    "(-1 for random)": "(-1 для случайного)",
    +    "Common prompt prefix": "Фиксированный префикс для промптов",
    +    "Preset for prompt prefix and negative prompt": "Пресет для префиксов промпта и для негативных промптов",
    +    "Style": "Стиль",
    +    "Negative common prompt prefix": "Фиксированный префикс для негативных промптов",
    +    "Chat Message Visibility (by source)": "Видимость сообщений в чате (по источникам)",
    +    "Uncheck to hide the extension's messages in chat prompts.": "Снимите галочку, чтобы скрыть из промптов сообщения, приходящие из этого источника.",
    +    "Extensions Menu": "Меню расширений",
    +    "Slash Command": "Слэш-команды",
    +    "Interactive Mode": "Интерактивный режим",
    +    "Function Tool": "Функция",
    +    "Save style": "Сохранить стиль",
    +    "Delete style": "Удалить стиль",
    +    "Are you sure you want to delete the style \"${0}\"?": "Вы точно хотите удалить стиль \"${0}\"?",
    +    "API Key": "Ключ от API",
    +    "These settings only apply to DALL-E 3": "Данные настройки применяются только для DALL-E 3",
    +    "Image Style": "Стиль изображения",
    +    "Image Quality": "Качество изображения",
    +    "Standard": "Стандарт",
    +    "sd_res_512x512": "512x512 (1:1, иконки, аватарки)",
    +    "sd_res_600x600": "600x600(1:1, иконки, аватарки)",
    +    "sd_res_512x768": "512x768 (2:3, вертикальная аватарка для карточки)",
    +    "sd_res_768x512": "768x512 (3:2, 35мм плёнка для кинофильмов, альбомн. ориентация)",
    +    "sd_res_960x540": "960x540 (16:9, обои, альбомн. ориентация)",
    +    "sd_res_540x960": "540x960 (9:16, обои, книжн. ориентация)",
    +    "sd_res_1920x1088": "1920x1088 (16:9, обои, альбомн. ориентация)",
    +    "sd_res_1088x1920": "1088x1920 (9:16, обои, книжн. ориентация)",
    +    "sd_res_1280x720": "1280x720 (16:9, обои, альбомн. ориентация)",
    +    "sd_res_720x1280": "720x1280 (9:16, обои, книжн. ориентация)",
    +    "Click to set": "Нажмите чтобы задать",
    +    "Prompt Upsampling": "Применять Prompt Upsampling",
    +    "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.": "При активации автоматически модифицирует промпт, чтобы сделать процесс генерации более креативным.",
    +    "Important:": "Важно:",
    +    "The server must be accessible from the SillyTavern host machine.": " Адрес должен быть доступен с сервера SillyTavern.",
    +    "Open workflow editor": "Открыть редактор воркфлоу",
    +    "Create new workflow": "Создать новый воркфлоу",
    +    "Delete workflow": "Удалить воркфлоу",
    +    "Delete the workflow? This action is irreversible.": "Удалить этот воркфлоу? Отменить будет невозможно.",
    +    "Swap width and height": "Поменять местами ширину и высоту",
    +    "Authentication (optional)": "Данные для аутентификации (необязательно)",
    +    "sd_auto_auth_warning_1": " запускайте Stable Diffusion с флагом",
    +    "sd_auto_auth_warning_2": "! Адрес SD должен быть доступен с сервера SillyTavern.",
    +    "Upscale by": "Множитель апскейлинга",
    +    "Hires steps (2nd pass)": "Кол-во шагов Hires (на втором проходе)"
     }
    
  • public/locales/th-th.json+1461 0 added
  • public/locales/uk-ua.json+2 0 modified
    @@ -317,6 +317,8 @@
         "flag": "прапорцем",
         "API key (optional)": "Ключ API (необов'язково)",
         "Server url": "URL-адреса сервера",
    +    "Electron Hub API Key": "Ключ API для Electron Hub",
    +    "Electron Hub Model": "Модель Electron Hub",
         "Example: http://127.0.0.1:5000": "Приклад: http://127.0.0.1:5000",
         "Custom model (optional)": "Власна модель (необов'язково)",
         "vllm-project/vllm": "vllm-project/vllm (режим оболонки OpenAI API)",
    
  • public/locales/vi-vn.json+2 0 modified
    @@ -317,6 +317,8 @@
         "flag": "cờ",
         "API key (optional)": "Key API (tùy chọn)",
         "Server url": "URL máy chủ",
    +    "Electron Hub API Key": "Key API Electron Hub",
    +    "Electron Hub Model": "Model Electron Hub",
         "Example: http://127.0.0.1:5000": "Ví dụ: http://127.0.0.1:5000",
         "Custom model (optional)": "Model tùy chỉnh (tùy chọn)",
         "vllm-project/vllm": "vllm-project/vllm (Chế độ trình bao bọc API OpenAI)",
    
  • public/locales/zh-cn.json+378 182 modified
    @@ -2,7 +2,7 @@
         "Favorite": "星标",
         "Tag": "标签",
         "Duplicate": "复制",
    -    "Persona": "用户角色",
    +    "Persona": "用户设定",
         "Delete": "删除",
         "AI Response Configuration": "AI响应配置",
         "AI Configuration panel will stay open": "AI配置面板将保持打开",
    @@ -21,10 +21,11 @@
         "novelaipresets": "NovelAI 预设",
         "Default": "默认",
         "openaipresets": "对话补全预设",
    +    "Bind presets to API connections": "将预设与 API 配置绑定",
         "Text Completion presets": "文本补全预设",
         "response legth(tokens)": "回复长度(以词符数计)",
         "Streaming": "流式传输",
    -    "Streaming_desc": "逐位显示生成的回复",
    +    "Streaming_desc": "逐词显示生成的回复",
         "context size(tokens)": "上下文长度(以词符数计)",
         "unlocked": "解锁",
         "Only enable this if your model supports context sizes greater than 8192 tokens": "仅在您的模型支持大于8192个词符的上下文长度时启用此选项",
    @@ -73,10 +74,12 @@
         "Context Size (tokens)": "上下文长度(以词符数计)",
         "Max Response Length (tokens)": "最大回复长度(以词符数计)",
         "Multiple swipes per generation": "每次生成多个备选回复",
    -    "Middle-out Transform": "Middle-out Transform",
    -    "Auto": "Auto",
    -    "Allow": "Allow",
    -    "Forbid": "Forbid",
    +    "Allow compressing requests by removing messages from the middle of the prompt.": "允许通过移除提示词中间的消息来压缩请求。",
    +    "Middle-out Transform": "中心向外算法",
    +    "Auto": "自动",
    +    "Allow": "允许",
    +    "Forbid": "禁止",
    +    "Unknown": "未知",
         "Enable OpenAI completion streaming": "启用OpenAI文本补全流式传输",
         "Display the response bit by bit as it is generated.": "随着回复的生成,逐词逐句地显示结果。",
         "When this is off, responses will be displayed all at once when they are complete.": "当此选项关闭时,回复将在完成后一次性显示。",
    @@ -147,8 +150,9 @@
         "Epsilon Cutoff": "ε 截断",
         "Epsilon cutoff sets a probability floor below which tokens are excluded from being sampled": "ε 截断设置了一个概率下限,低于该下限的词符将被排除在采样之外。\n以 1e-4 单位;合适的值为 3。将其设置为 0 以禁用。",
         "Top nsigma": "Top nsigma",
    +    "Min Keep": "Min Keep",
         "Eta Cutoff": "η 截断",
    -    "Eta_Cutoff_desc": "η截断是特殊η采样技术的主要参数。&#13;以1e-4为单位;合理的值为3。&#13;设置为0以禁用。&#13;有关详细信息,请参阅Hewitt等人的论文《Truncation Sampling as Language Model Desmoothing》(2022年)。",
    +    "Eta_Cutoff_desc": "η截断是特殊η采样技术的主要参数。以1e-4为单位;合理的值为3。设置为0以禁用。详细请参阅《Truncation Sampling as Language Model Desmoothing》(Hewitt et al., 2022)。",
         "rep.pen decay": "重复惩罚衰减",
         "Encoder Rep. Pen.": "编码器重复惩罚",
         "No Repeat Ngram Size": "无重复n-gram大小",
    @@ -187,9 +191,9 @@
         "Beam search": "束搜索",
         "A greedy, brute-force algorithm used in LLM sampling to find the most likely sequence of words or tokens. It expands multiple candidate sequences at once, maintaining a fixed number (beam width) of top sequences at each step.": "一种在LLM采样中使用的贪婪暴力算法,用于找到最可能的单词或标记序列。它一次扩展多个候选序列,在每一步保留固定数量(光束宽度)的最佳序列。",
         "# of Beams": "光束数量",
    -    "The number of sequences generated at each step with Beam Search.": "The number of sequences generated at each step with Beam Search.",
    +    "The number of sequences generated at each step with Beam Search.": "束搜索每步生成的序列数量。",
         "Length Penalty": "长度惩罚",
    -    "Penalize sequences based on their length.": "Penalize sequences based on their length.",
    +    "Penalize sequences based on their length.": "根据序列长度对其进行惩罚。",
         "Early Stopping": "提前停止",
         "Controls the stopping condition for beam search. If checked, the generation stops as soon as there are '# of Beams' sequences. If not checked, a heuristic is applied and the generation is stopped when it's very unlikely to find better candidates.": "控制光束搜索的停止条件。勾选时,当生成到达‘光束数量’的序列时停止。如果未勾选,则采用启发式方法,当几乎不可能找到更好的候选项时停止生成。",
         "Contrastive search": "对比搜索",
    @@ -203,7 +207,7 @@
         "Ignore EOS Token": "忽略序列结束词符",
         "Ignore the EOS Token even if it generates.": "即使生成了序列结束词符,也忽略它。",
         "Skip Special Tokens": "跳过特殊词符",
    -    "Request Model Reasoning": "Request Model Reasoning",
    +    "Request Model Reasoning": "请求模型推理",
         "Temperature Last": "温度放最后",
         "Temperature_Last_desc": "温度采样器放到最后使用。这通常是合理的。\n当启用时:首先进行潜在词符的选择,然后应用温度来修正它们的相对概率(技术上是对数似然)。\n当禁用时:首先应用温度来修正所有词符的相对概率,然后从中选择潜在词符。\n禁用此项可以增大分布在尾部的词符概率,这可能加大得到不相关回复的几率。",
         "Speculative Ngram": "推测性 Ngram",
    @@ -254,10 +258,16 @@
         "Continue sends the last message as assistant role instead of system message with instruction.": "继续发送的是作为助手角色的最后一条消息,而不是带有指示的系统消息。",
         "Squash system messages": "压缩系统消息",
         "Combines consecutive system messages into one (excluding example dialogues). May improve coherence for some models.": "将连续的系统消息合并为一条(不包括示例对话),可能会提高一些模型的连贯性。",
    +    "Enable web search": "启用联网搜索",
    +    "Use search capabilities provided by the backend.": "使用后端提供的联网搜索功能。",
    +    "openrouter_web_search_fee": "收费,每个提示词会多收 $0.02。",
         "Enable function calling": "启用函数调用",
    +    "Supported by the current model": "当前模型支持",
    +    "Unsupported by the current model": "当前模型不支持",
         "enable_functions_desc_1": "允许使用",
         "enable_functions_desc_2": "功能工具",
         "enable_functions_desc_3": "可以被各种扩展利用来提供附加功能。",
    +    "enable_functions_desc_4": "当提示词后处理没有选择工具时不支持。",
         "Send inline images": "发送图片",
         "image_inlining_hint_1": "如果模型支持,就可以在提示词中发送图片。\n发送消息时,点击",
         "image_inlining_hint_2": "在这里(",
    @@ -266,23 +276,38 @@
         "openai_inline_image_quality_auto": "自动",
         "openai_inline_image_quality_low": "低",
         "openai_inline_image_quality_high": "高",
    +    "Send inline videos": "发送视频",
    +    "video_inlining_hint_1": "当模型支持时,将视频发送给模型。使用",
    +    "video_inlining_hint_2": "在任意消息上添加视频,或",
    +    "video_inlining_hint_3": "菜单来添加视频。",
    +    "video_inlining_hint_4": "视频必须在 20MB 以下且时长不超过1分钟。",
    +    "Request inline images": "请求图片返回",
    +    "Allows the model to return image attachments.": "允许模型返回图片附件。",
    +    "Request inline images_desc_2": "与以下几个功能不兼容:函数调用、联网搜搜、系统提示词。",
         "Use system prompt": "使用系统提示词",
         "Merges_all_system_messages_desc_1": "合并所有系统消息,直到第一条具有非系统角色的消息,然后通过",
         "Merges_all_system_messages_desc_2": "字段发送。",
         "Request model reasoning": "请求思维链",
         "Allows the model to return its thinking process.": "允许模型返回其思维过程。",
    +    "This setting affects visibility only.": "此设置只影响思维链是否可见。",
         "Constrains effort on reasoning for reasoning models.": "限定模型推理的强度。\n当前支持低、中、高三种强度。\n降低推理强度可以让模型更快回复,并节省推理所用的词符数。。",
         "Reasoning Effort": "推理强度",
    +    "openai_reasoning_effort_auto": "自动",
    +    "openai_reasoning_effort_minimum": "极低",
         "openai_reasoning_effort_low": "低",
         "openai_reasoning_effort_medium": "中",
         "openai_reasoning_effort_high": "高",
    +    "openai_reasoning_effort_maximum": "极高",
    +    "OpenAI-style options: low, medium, high. Minimum and maximum are aliased to low and high. Auto does not send an effort level.": "OpenAI式选项:低、中、高。极低等于低,极高等于高。选择自动,则不传入推理强度参数。",
    +    "Allocates a portion of the response length for thinking (min: 1024 tokens, low: 10%, medium: 25%, high: 50%, max: 95%), but minimum 1024 tokens. Auto does not request thinking.": "将最大回复空间的一部分分配给思维链(极低:1024词符,低:10%,中:25%,高:50%,极高:95%),最低1024词符。选择“自动”不会请求模型思维链。",
    +    "Allocates a portion of the response length for thinking (Flash 2.5/Pro 2.5) (min: 0/128 tokens, low: 10%, medium: 25%, high: 50%, max: 24576/32768 tokens). Auto lets the model decide.": "将最大回复空间的一部分分配给思维链(仅 2.5 Flash / 2.5 Pro 模型)(极低:0/128词符,低:10%,中:25%,高:50%,极高:24576/32768词符),最低1024词符。选择“自动”会让模型自己决定。",
         "Assistant Prefill": "AI预填",
         "Expand the editor": "展开编辑器",
         "Start Claude's answer with...": "以如下内容开始Claude的回答...",
         "Assistant Impersonation Prefill": "AI帮答预填",
         "Send the system prompt for supported models. If disabled, the user message is added to the beginning of the prompt.": "为支持的模型发送系统提示词。如果禁用,则用户消息将添加到提示词的开头。",
         "Confirm token parsing with": "确认使用以下工具进行词符解析",
    -    "Tokenizer": "词符化器",
    +    "Tokenizer": "分词器",
         "New preset": "新预设",
         "Delete preset": "删除预设",
         "View / Edit bias preset": "查看/编辑偏置预设",
    @@ -305,14 +330,17 @@
         "Adjust response length to worker capabilities": "根据工作单元能力调整响应长度",
         "Can help with bad responses by queueing only the approved workers. May slowdown the response time.": "可以通过仅排队认证的工作单元来帮助处理不良回复。这可能会减慢回复速度。",
         "Trusted workers only": "仅信任的工作单元",
    +    "Context": "上下文",
    +    "Response": "回复",
         "API key": "API密钥",
         "Get it here:": "在此获取:",
         "Register": "注册",
         "View my Kudos": "查看我的荣誉",
         "Enter": "输入",
         "to use anonymous mode.": "以使用匿名模式。",
    -    "Clear your API key": "清除您的API密钥",
    -    "For privacy reasons, your API key will be hidden after you reload the page.": "出于隐私原因,重新加载页面后您的 API 密钥将被隐藏。",
    +    "Save and connect": "保存并连接",
    +    "Manage API keys": "管理 API 密钥",
    +    "For privacy reasons, your API key will be hidden after you click 'Connect'.": "出于隐私考虑,您的 API 密钥将会在点击“连接”后隐藏。",
         "Models": "模型",
         "Refresh models": "刷新模型",
         "-- Horde models not loaded --": "-- Horde 模型未加载 --",
    @@ -351,14 +379,16 @@
         "Make sure you run it with": "确保您在运行时加上",
         "flag": "标志",
         "Custom model (optional)": "自定义模型(可选)",
    -    "Featherless Model Selection": "Featherless Model Selection",
    +    "Featherless Model Selection": "Featherless 模型选择",
         "Search...": "搜索...",
         "Search": "搜索",
    +    "Date Asc": "日期顺序",
    +    "Date Desc": "日期倒序",
         "category": "分类",
    -    "Top": "Top",
    +    "Top": "热门",
         "New": "新建",
    -    "All": "All",
    -    "class": "All Classes",
    +    "All": "全部",
    +    "All Classes": "所有分类",
         "Toggle grid view": "切换网格视图",
         "No model description": "[无描述]",
         "vllm-project/vllm": "vllm-project/vllm(OpenAI API 包装器模式)",
    @@ -378,6 +408,7 @@
         "Download": "下载",
         "Tabby API key": "Tabby API 密钥",
         "Tabby Model": "Tabby 模型",
    +    "Experimental feature. Use at your own risk.": "实验性功能,请自行承担可能的风险。",
         "must be set in Tabby's config.yml to switch models.": "必须在Tabby的config.yml内设置以切换模型",
         "Use an admin API key.": "使用管理员API密钥。",
         "koboldcpp API key (optional)": "koboldcpp API 密钥(可选)",
    @@ -397,8 +428,9 @@
         "This will show up as your saved preset.": "这将显示为您保存的预设。",
         "Proxy Server URL": "代理服务器 URL",
         "Alternative server URL (leave empty to use the default value).": "备用服务器 URL(留空以使用默认值)。",
    -    "Doesn't work? Try adding": "不起作用?在末尾添加",
    +    "Doesn't work? Try adding": "不行?在 URL 末尾添加",
         "at the end!": "试试!",
    +    "suffix will be added automatically.": "的后缀会被自动补全。",
         "Proxy Password": "代理密码",
         "Will be used as a password for the proxy instead of API key.": "将用作代理的密码,而不是 API 密钥。",
         "Peek a password": "查看密码",
    @@ -418,8 +450,6 @@
         "Get your key from": "从以下位置获取您的密钥",
         "Anthropic's developer console": "Anthropic 开发者控制台",
         "Claude Model": "Claude 模型",
    -    "Window AI Model": "Window AI 模型",
    -    "Use extension settings": "使用扩展程序中的设定",
         "Allow fallback routes Description": "如果所选模型无法响应您的请求,则自动选择备用模型。",
         "Allow fallback models": "允许后备模型",
         "Model Order": "OpenRouter 模型顺序",
    @@ -428,41 +458,72 @@
         "Context Size": "上下文长度",
         "Group by vendors": "按厂商分组",
         "Group by vendors Description": "将 OpenAI 模型放在一组,将 Anthropic 模型放在另一组,等等。可以与排序结合。",
    -    "To use instruct formatting, switch to OpenRouter under Text Completion API.": "To use instruct formatting, switch to OpenRouter under Text Completion API.",
    +    "To use instruct formatting, switch to OpenRouter under Text Completion API.": "要使用指导格式,请在 文字补全API 下切换到 OpenRouter。",
         "AI21 API Key": "AI21 API 密钥",
         "AI21 Model": "AI21 模型",
         "Google AI Studio API Key": "Google AI Studio API 密钥",
         "Google Model": "Google 模型",
    +    "Google Vertex AI Configuration": "Google Vertex AI 配置",
    +    "Authentication Mode": "验证模式:",
    +    "Express Mode (API Key)": "快速模式(API 密钥)",
    +    "Full Version (Service Account)": "完整版本(Service Account)",
    +    "(Express mode)": "(快速模式)",
    +    "API Key": "API 密钥",
    +    "Project ID": "项目ID:",
    +    "Project ID is required when selecting regions other than the default (us-central1). You can find this in a model 404 error message.": "仅当选择非默认区域(us-central1)时才需要`项目ID`。\n 您可以在模型 404 错误消息中找到它。",
    +    "Service Account Configuration": "服务帐户配置",
    +    "Service Account JSON Content": "服务帐户 JSON 内容:",
    +    "For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.": "出于隐私考虑,你的服务账号 JSON 内容将在点击“验证JSON”后隐藏。",
    +    "Validate JSON": "验证JSON",
    +    "Region": "地区:",
    +    "View available regions and models": "查看可用地区和模型",
         "MistralAI API Key": "MistralAI API 密钥",
         "MistralAI Model": "MistralAI 模型",
         "Groq API Key": "Groq API 密钥",
         "Groq Model": "Groq 模型",
    -    "NanoGPT API Key": "NanoGPT API Key",
    -    "NanoGPT Model": "NanoGPT Model",
    +    "Electron Hub API Key": "Electron Hub API 密钥",
    +    "Electron Hub Model": "Electron Hub 模型",
    +    "NanoGPT API Key": "NanoGPT API 密钥",
    +    "NanoGPT Model": "NanoGPT 模型",
         "DeepSeek API Key": "DeepSeek API 密钥",
         "DeepSeek Model": "DeepSeek 模型",
    +    "Fireworks AI API Key": "Fireworks AI API 密钥",
    +    "Fireworks AI Model": "Fireworks AI 模型",
    +    "CometAPI API Key": "CometAPI API 密钥",
    +    "CometAPI Model": "CometAPI 模型",
         "Perplexity API Key": "Perplexity API 密钥",
         "Perplexity Model": "Perplexity 模型",
         "Cohere API Key": "Cohere API 密钥",
         "Cohere Model": "Cohere 模型",
    -    "Block Entropy API Key": "Block Entropy API 密钥",
    -    "Select a Model": "选择一个模型",
         "Custom Endpoint (Base URL)": "自定义端点(基础 URL)",
         "Example: http://localhost:1234/v1": "例如:http://localhost:1234/v1",
         "Custom API Key": "自定义 API 密钥",
         "(Optional)": "(可选)",
         "Enter a Model ID": "输入模型名",
         "Example: gpt-4o": "例如:gpt-4o",
         "Available Models": "可用模型",
    +    "xAI API Key": "xAI API 密钥",
    +    "xAI Model": "xAI 模型",
    +    "AI/ML API Key": "AI/ML API 密钥",
    +    "AI/ML Model": "AI/ML 模型",
    +    "Pollinations Model": "Pollinations 模型",
    +    "Provided free of charge by Pollinations.AI": "由 Pollinations.AI 免费提供",
    +    "Avoid sending sensitive information. Provider's outputs may include ads.": "请避免发送敏感信息。输出可能有提供商的广告。",
    +    "Moonshot AI API Key": "Moonshot AI API 密钥",
    +    "Moonshot AI Model": "Moonshot AI 模型",
         "Prompt Post-Processing": "提示词后处理",
         "Applies additional processing to the prompt before sending it to the API.": "在将提示词发送到 API 之前对其进行额外处理。",
         "prompt_post_processing_none": "未选择",
    +    "prompt_post_processing_merge_tools": "合并相同角色连续的发言(含工具)",
    +    "prompt_post_processing_semi_tools": "半严格(强制对话角色交替)(含工具)",
    +    "prompt_post_processing_strict_tools": "严格(强制对话角色交替、用户最先)(含工具)",
         "prompt_post_processing_merge": "合并相同角色连续的发言",
         "prompt_post_processing_semi": "半严格(强制对话角色交替)",
         "prompt_post_processing_strict": "严格(强制对话角色交替、用户最先)",
    +    "prompt_post_processing_single": "单一用户消息(无工具)",
         "Additional Parameters": "附加参数",
    -    "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "通过发送简短的测试消息验证您的API连接。请注意,您将因此消耗额度!",
         "Test Message": "发送测试消息",
    +    "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "通过发送简短的测试消息验证您的API连接。请注意,您将因此消耗额度!",
         "Auto-connect to Last Server": "自动连接到上次的服务器",
         "Missing key": "❌ 缺少密钥",
         "Key saved": "密钥已保存",
    @@ -484,6 +545,14 @@
         "Restore current template": "还原当前模板",
         "Delete the template": "删除模板",
         "Story String": "故事字符串",
    +    "Position:": "位置:",
    +    "Default (top of context)": "默认(上下文顶部)",
    +    "In-chat @ Depth": "聊天的特定深度",
    +    "Depth:": "深度:",
    +    "Role:": "身份:",
    +    "System": "系统",
    +    "User": "用户",
    +    "Assistant": "助手",
         "Example Separator": "示例分隔符",
         "Chat Start": "聊天开始",
         "Context Formatting": "上下文格式",
    @@ -497,7 +566,6 @@
         "Separators as Stop Strings": "分隔符作为终止字符串",
         "Add Character and User names to a list of stopping strings.": "将角色和用户名添加到停止字符串列表中。",
         "Names as Stop Strings": "名称作为终止字符串",
    -    "context_allow_post_history_instructions": "如果在角色卡中定义并且启用了“首选角色卡说明”,则在提示末尾包含后历史说明。\n不建议在文本补全模型中使用此功能,否则会导致输出错误。",
         "Instruct Template": "指导模板",
         "instruct_derived": "如果可能,从模型元数据中获取",
         "instruct_bind_to_context": "如果启用,上下文模板将根据所选的指导模板名称或偏好自动选择。",
    @@ -508,12 +576,19 @@
         "instruct_template_activation_regex_desc": "当连接到API或选择模型时,若模型名称与给定的正则表达式匹配,自动启用此指导模板。",
         "Wrap Sequences with Newline": "用换行符包裹序列",
         "Replace Macro in Sequences": "替换序列中的宏",
    +    "Sequences as Stop Strings": "将序列用作终止字符串",
         "Skip Example Dialogues Formatting": "跳过示例对话格式化",
         "Include Names": "包括名称",
         "Never": "永不",
    -    "Groups and Past Personas": "群聊和过去的用户角色",
    +    "Groups and Past Personas": "群聊和过去的用户设定",
         "Always": "永远",
         "Instruct Sequences": "指令序列",
    +    "Story String Sequences": "故事字符串序列",
    +    "Used in Default position only.": "仅在默认位置使用。",
    +    "Inserted before a Story String.": "插入在故事字符串之前。",
    +    "Story String Prefix": "故事字符串前缀",
    +    "Inserted after a Story String.": "插入在故事字符串之后。",
    +    "Story String Suffix": "故事字符串后缀",
         "User Message Sequences": "用户消息序列",
         "Inserted before a User message and as a last prompt line when impersonating.": "插入到用户消息之前并作为模拟时的最后一行提示词。",
         "User Prefix": "用户消息前缀",
    @@ -531,11 +606,6 @@
         "System Suffix": "系统消息后缀",
         "If enabled, System Sequences will be the same as User Sequences.": "如果启用,系统序列将与用户序列相同。",
         "System same as User": "系统与用户相同",
    -    "System Prompt Sequences": "系统提示词序列",
    -    "Inserted before a System prompt.": "插入到系统提示词之前。",
    -    "System Prompt Prefix": "系统提示词前缀",
    -    "Inserted after a System prompt.": "在系统提示词后插入。",
    -    "System Prompt Suffix": "系统提示词后缀",
         "Misc. Sequences": "杂项序列",
         "Inserted before the first Assistant's message.": "插入到第一个助理的消息之前。",
         "First Assistant Prefix": "第一个助理前缀",
    @@ -565,24 +635,27 @@
         "Replace Macro in Stop Strings": "替换自定义停止字符串中的宏",
         "Token Padding": "词符填充",
         "Reasoning": "推理",
    -    "reasoning_auto_parse": "Automatically parse reasoning blocks from main content between the reasoning prefix/suffix. Both fields must be defined and non-empty.",
    +    "reasoning_auto_parse": "自动从消息内容中提取由推理块前后缀包裹的推理块。前后缀都必须设置且不为空。",
         "Auto-Parse": "自动解析",
         "reasoning_auto_expand": "自动展开推理内容块。",
         "Auto-Expand": "自动展开",
         "reasoning_show_hidden": "对于隐藏推理内容的模型,展示其推理用时。",
         "Show Hidden": "显示隐藏内容",
         "reasoning_add_to_prompts": "将已有的推理块添加到提示词。若需新增一个推理块,请使用消息编辑菜单。",
         "Add to Prompts": "添加到提示词",
    -    "reasoning_max_additions": "Maximum number of reasoning blocks to be added per prompt, counting from the last message.",
    +    "reasoning_max_additions": "从最后一条消息开始,最多有多少个思维链可以被加入提示词。",
         "Max": "最大值",
         "Reasoning Formatting": "推理内容格式化",
    +    "Select your current Reasoning Template": "选择你当前的推理模板",
         "reasoning_prefix": "插入在推理内容之前。",
         "Prefix": "前缀",
         "reasoning_suffix": "插入在推理内容之后。",
         "Suffix": "后缀",
         "reasoning_separator": "插入在推理内容和消息内容之间。",
         "Separator": "分隔符",
         "Miscellaneous": "杂项",
    +    "Bind Model to Templates": "将模型与模板绑定",
    +    "bind_model_templates_desc": "当连接到一个API或选择一个模型,且它们的名字与当前的指导和上下文模板名字匹配时,自动激活当前的指导和上下文模板。",
         "Non-markdown strings": "非 Markdown 字符串",
         "comma delimited,no spaces between": "以逗号分隔,无需空格",
         "Start Reply With": "以...开始回复",
    @@ -623,20 +696,20 @@
         "Alert On Overflow": "溢出警报",
         "or": "或",
         "--- Pick to Edit ---": "--- 选择以编辑 ---",
    +    "Import World Info": "导入世界书",
    +    "Export World Info": "导出世界书",
         "Rename World Info": "重命名世界书",
    +    "Duplicate World Info": "复制世界书",
    +    "Delete World Info": "删除世界书",
    +    "New Entry": "新条目",
         "Open all Entries": "打开所有条目",
         "Close all Entries": "关闭所有条目",
    -    "New Entry": "新条目",
         "Fill empty Memo/Titles with Keywords": "使用关键字填充空的备忘录/标题",
         "Apply current sorting as Order": "应用当前排序作为顺序",
    -    "Import World Info": "导入世界书",
    -    "Export World Info": "导出世界书",
    -    "Duplicate World Info": "复制世界书",
    -    "Delete World Info": "删除世界书",
         "Priority": "优先级",
         "Custom": "自定义",
    -    "Title A-Z": "标题 A-Z",
    -    "Title Z-A": "标题 Z-A",
    +    "Title A-Z": "标题 A 到 Z",
    +    "Title Z-A": "标题 Z 到 A",
         "Tokens ↗": "词符 ↗",
         "Tokens ↘": "词符 ↘",
         "Depth ↗": "深度 ↗",
    @@ -663,11 +736,19 @@
         "Avatar Style:": "头像样式:",
         "Circle": "圆形",
         "Square": "正方形",
    +    "Rounded": "圆角",
         "Rectangle": "矩形",
         "Chat Style:": "聊天风格:",
         "Flat": "扁平",
         "Bubbles": "气泡",
         "Document": "文档",
    +    "Notifications:": "通知:",
    +    "Top Left": "左上",
    +    "Top Center": "顶部居中",
    +    "Top Right": "右上",
    +    "Bottom Left": "左下",
    +    "Bottom Center": "底部居中",
    +    "Bottom Right": "右下",
         "Specify colors for your theme.": "指定您的主题的颜色。",
         "Theme Colors": "主题颜色",
         "Main Text": "主要文本",
    @@ -726,6 +807,8 @@
         "Show tagged character folders in the character list": "在角色列表中显示已标记的角色文件夹",
         "Tags as Folders": "标签作为文件夹",
         "Tags_as_Folders_desc": "最近更改:标签必须在标签管理菜单中标记为文件夹才能显示。单击此处将其调出。",
    +    "Click the message text in the chat log to edit it.": "单机聊天记录中的消息就可以直接编辑。",
    +    "Click to Edit": "单击编辑消息",
         "Character Handling": "角色处理",
         "If set in the advanced character definitions, this field will be displayed in the characters list.": "如果在高级角色定义中设置,此字段将显示在角色列表中。",
         "Char List Subheader": "角色列表子标题",
    @@ -745,13 +828,17 @@
         "Prefer Character Card Instructions": "首选角色卡说明",
         "never_resize_avatars_tooltip": "避免裁剪和调整导入的角色图像的大小。关闭时,裁剪/调整大小为 512x768。",
         "Never resize avatars": "永不调整头像大小",
    +    "Allow animations for WEBP backgrounds. This is only a change for the selection menu.": "允许WEBP格式的背景动画。此更改仅对选择菜单生效",
    +    "Animated background thumbnails": "背景缩略图动画",
         "Show actual file names on the disk, in the characters list display only": "在角色列表显示中,显示磁盘上实际的文件名。",
         "Show avatar filenames": "显示头像文件名",
         "Hide character definitions from the editor panel behind a spoiler button": "在编辑器面板中,将角色定义隐藏在一个剧透按钮后面。",
         "Spoiler Free Mode": "防剧透模式",
         "Reload and redraw the currently open chat": "重新加载并重新渲染当前打开的聊天",
         "Reload Chat": "重新加载聊天",
         "Debug Menu": "调试菜单",
    +    "Find and delete backups, unused chats, files, images, etc.": "寻找和删除备份、未使用的聊天、文件、图片等。",
    +    "Clean-Up": "清理",
         "Smooth Streaming": "平滑流式传输",
         "Experimental feature. May not work for all backends.": "实验性功能。可能不适用于所有后端。",
         "Slow": "慢",
    @@ -821,6 +908,8 @@
         "Request token probabilities": "请求词符概率",
         "In group chat, highlight the character(s) that are currently queued to generate responses and the order in which they will respond.": "在群聊中,突出显示当前排队等待生成响应的角色以及他们响应的顺序。",
         "Show group chat queue": "显示群聊队列",
    +    "Always render style tags from greetings, even if the message is unloaded due to lazy loading.": "始终渲染问候消息里的样式标签,即便消息因懒加载策略还未被加载。",
    +    "Pin greeting message styles": "固定问候消息样式",
         "Automatically reject and re-generate AI message based on configurable criteria": "根据可配置的条件自动拒绝并重新生成AI消息",
         "Auto-swipe": "自动滑动",
         "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "启用自动滑动功能。仅当启用自动滑动时,本节中的设置才会生效",
    @@ -835,6 +924,10 @@
         "Allow for Chat Completion APIs": "允许使用聊天补全API",
         "Target length (tokens)": "目标长度(以词符数计)",
         "AutoComplete Settings": "自动补全设置",
    +    "Visibility": "可见性",
    +    "Don't show": "不显示",
    +    "Input length > 1": "输入长度 > 1",
    +    "Always show": "始终显示",
         "Automatically hide details": "自动隐藏详细信息",
         "Determines how entries are found for autocomplete.": "确定如何找到自动补全的条目。",
         "Autocomplete Matching": "匹配",
    @@ -863,8 +956,7 @@
         "stscript_parser_flag_replace_getvar_label": "防止 {{getvar::}} {{getglobalvar::}} 宏具有自动评估的文字宏类值。\n例如,“{{newline}}”保留为文字字符串“{{newline}}”\n\n(这是通过在内部用范围变量替换 {{getvar::}} {{getglobalvar::}} 宏来实现的。)",
         "REPLACE_GETVAR": "替换GETVAR",
         "Change Background Image": "更改背景图片",
    -    "Background Image": "背景图片",
    -    "Filter": "搜索",
    +    "Backgrounds": "背景",
         "Background Fitting": "背景图片尺寸",
         "Classic": "经典",
         "Cover": "填充",
    @@ -873,50 +965,58 @@
         "Center": "居中",
         "Automatically select a background based on the chat context": "根据聊天上下文自动选择背景",
         "Auto-select": "自动选择",
    +    "Add Background": "添加背景",
         "System Backgrounds": "系统背景",
         "Chat Backgrounds": "聊天背景",
         "bg_chat_hint_1": "使用生成的聊天背景",
         "bg_chat_hint_2": "扩展名将出现在这里。",
         "Extensions": "扩展",
         "Notify on extension updates": "在扩展更新时通知",
         "Manage extensions": "管理扩展",
    -    "Import Extension From Git Repo": "从Git存储库导入扩展",
    +    "Import Extension From Git Repo": "从 Git 仓库导入扩展",
         "Install extension": "安装扩展",
    +    "(DEPRECATED)": "(已弃用)",
         "Extras API:": "扩展API:",
         "Auto-connect": "自动连接",
         "Extras API URL": "附加 API URL",
         "Extras API key (optional)": "扩展API密钥(可选)",
    -    "Persona Management": "用户角色管理",
    -    "Click for stats!": "点击查看统计!",
    +    "Persona Management": "用户设定管理",
    +    "Click for stats!": "点击即可查看统计~",
         "Usage Stats": "使用统计",
    -    "Backup your personas to a file": "将用户角色备份到文件中",
    +    "Backup your personas to a file": "将用户设定备份到文件中",
         "Backup": "备份",
    -    "Restore your personas from a file": "从文件中恢复用户角色",
    +    "Restore your personas from a file": "从文件中恢复用户设定",
         "Restore": "恢复",
    -    "Create a dummy persona": "创建空白用户角色",
    +    "Create a dummy persona": "创建空白用户设定",
         "Create": "创建",
    -    "No persona description": "[没有描述]",
    -    "Name": "名称",
    -    "Enter your name": "输入您的名字",
    -    "Click to set a new User Name": "点击设置新的用户名",
    -    "Click to lock your selected persona to the current chat. Click again to remove the lock.": "单击以将您选择的用户角色锁定到当前聊天。再次单击以移除锁定。",
    +    "No persona description": "[没有人设描述]",
    +    "Current Persona": "当前人设",
    +    "Rename Persona": "重命名人设",
         "Click to set user name for all messages": "点击为所有消息设置用户名",
         "Persona Lore Alt+Click to open the lorebook": "Persona Lore\nAlt+Click to open the lorebook",
    -    "Persona Description": "用户角色描述",
    +    "Change Persona Image": "更改人设图",
    +    "Duplicate Persona": "复制人设",
    +    "Delete Persona": "删除人设",
    +    "Persona Description": "用户设定描述",
         "Example: [{{user}} is a 28-year-old Romanian cat girl.]": "示例:[{{user}}是一个28岁的罗马尼亚猫娘。]",
    -    "Tokens persona description": "用户角色描述词符数",
    -    "Position:": "位置:",
    +    "Position": "插入位置",
    +    "Tokens persona description": "人设词符数",
         "None (disabled)": "无(已禁用)",
         "In Story String / Prompt Manager": "在故事字符串/提示词管理器中",
         "Top of Author's Note": "作者注的顶部",
         "Bottom of Author's Note": "作者注的底部",
    -    "In-chat @ Depth": "聊天的特定深度",
    -    "Depth:": "深度:",
    -    "Role:": "身份:",
    -    "System": "系统",
    -    "User": "用户",
    -    "Assistant": "助手",
    -    "Show notifications on switching personas": "切换用户角色时显示通知",
    +    "Connections": "链接",
    +    "Click to select this as default persona for the new chats. Click again to remove it.": "点击将此人设设置为新聊天的默认人设。再次点击以移除。",
    +    "Click to lock your selected persona to the current character. Click again to remove the lock.": "点击将选择的用户设定与当前角色绑定。再次点击以解绑。",
    +    "Character": "角色",
    +    "Click to lock your selected persona to the current chat. Click again to remove the lock.": "点击将选择的人设与当前聊天绑定。再次点击以解绑。",
    +    "Chat": "聊天",
    +    "Global Settings": "全局设置",
    +    "Show notifications on switching personas": "切换用户设定时显示通知",
    +    "When multiple personas are connected to a character, a popup will appear to select which one to use": "当多个用户设定与一个角色绑定时,会弹出一个弹窗让用户选择使用哪一个。",
    +    "Allow multiple persona connections per character": "允许每个角色与多个用户设定绑定",
    +    "Whenever a persona is selected, it will be locked to the current chat and automatically selected when the chat is opened.": "当一个用户设定被选择,它会被自动绑定到当前聊天,并在此聊天后续打开时被自动选中。",
    +    "Auto-lock a chosen persona to the chat": "自动将选择的用户设定绑定到聊天",
         "Character Management": "角色管理",
         "Locked = Character Management panel will stay open": "锁定 = 角色管理面板将保持打开状态",
         "Select/Create Characters": "选择/创建角色",
    @@ -934,8 +1034,9 @@
         "Click to select a new avatar for this character": "单击以为此角色选择新的头像",
         "Add to Favorites": "添加到收藏夹",
         "Advanced Definition": "高级定义",
    -    "world_button_title": "Character Lore\n\nClick to load\nShift-click to open 'Link to World Info' popup",
    -    "Chat Lore Alt+Click to open the lorebook": "Chat Lore\nAlt+Click to open the lorebook",
    +    "world_button_title": "角色世界书\n\n单击加载\nShift+单击打开“链接到世界信息”弹出窗口",
    +    "Chat Lore Alt+Click to open the lorebook": "聊天世界书\nAlt+单击打开世界书",
    +    "Connected Personas": "绑定的用户设定",
         "Export and Download": "导出并下载",
         "Duplicate Character": "复制角色",
         "Create Character": "创建角色",
    @@ -949,11 +1050,14 @@
         "Link to Source": "来源链接",
         "Replace / Update": "替换 / 更新",
         "Import Tags": "导入标签",
    +    "Set / Unset as Welcome Page Assistant": "设置 / 取消 为欢迎页面助理",
         "Search / Create Tags": "搜索/创建标签",
         "View all tags": "查看所有标签",
         "Creator's Notes": "创作者的注释",
    -    "Character details are hidden.": "角色详情已隐藏。",
    +    "Allow / Forbid the use of global styles for this character.": "允许 / 禁止 该角色使用全局样式。",
         "Show / Hide Description and First Message": "显示/隐藏描述和第一条消息",
    +    "No Creator's Notes provided.": "无创作者注释",
    +    "Character details are hidden.": "角色详情已隐藏。",
         "Character Description": "角色描述",
         "Click to allow/forbid the use of external media for this character.": "单击以允许/禁止此角色使用外部媒体。",
         "Ext. Media": "扩展媒体",
    @@ -970,6 +1074,7 @@
         "Manual": "手动",
         "Natural order": "自然顺序",
         "List order": "从上到下",
    +    "Pooled order": "随机轮流顺序",
         "Group generation handling mode": "群组生成处理模式",
         "Swap character cards": "交换角色卡",
         "Join character cards (exclude muted)": "加入角色卡(不包括被禁言的)",
    @@ -987,7 +1092,9 @@
         "Auto Mode delay": "自动模式延迟",
         "Hide Muted Member Sprites": "隐藏拼贴头像中被禁言的成员",
         "Current Members": "当前成员",
    +    "Group is empty.": "暂无成员",
         "Add Members": "添加成员",
    +    "No characters available": "无可用角色",
         "Create New Character": "新建角色",
         "Import Character from File": "从文件导入角色",
         "Import content from external URL": "从外部URL导入内容",
    @@ -1004,15 +1111,13 @@
         "Most tokens": "最多词符",
         "Least tokens": "最少词符",
         "Random": "随机",
    +    "Toggle search bar": "切换搜索栏",
         "Toggle character grid view": "切换角色网格视图",
         "Bulk_edit_characters": "批量编辑角色",
         "Bulk select all characters": "批量选择所有角色",
         "Bulk delete characters": "批量删除角色",
    -    "Bind user name to that avatar": "将用户名称绑定到该头像",
    -    "Change persona image": "更改用户角色头像",
    -    "Select this as default persona for the new chats.": "选择此项作为新聊天的默认用户角色。",
    -    "Duplicate persona": "复制用户角色",
    -    "Delete persona": "删除用户角色",
    +    "Persona is locked to the current chat": "用户设定已被绑定到当前聊天",
    +    "Persona is locked to the current character": "用户设定已被绑定到当前角色",
         "popup-button-save": "保存",
         "popup-button-yes": "是",
         "popup-button-no": "否",
    @@ -1058,10 +1163,10 @@
         "Chat History": "聊天记录",
         "Import Chat": "导入聊天",
         "Copy to system backgrounds": "复制到系统背景",
    -    "Rename background": "重命名背景",
         "Lock": "锁定",
         "Unlock": "解锁",
    -    "Delete background": "删除背景",
    +    "Rename Background": "重命名背景",
    +    "Delete Background": "删除背景",
         "Select a World Info file for": "选择一个世界书文件给",
         "Primary Lorebook": "主要知识书",
         "A selected World Info will be bound to this character as its own Lorebook.": "所选的世界信息将会于该角色绑定,作为该角色自己的知识书",
    @@ -1076,30 +1181,8 @@
         "Delete chat file": "删除聊天文件",
         "Drag to reorder tag": "拖动以排序",
         "Use tag as folder": "标记为文件夹",
    -    "Hide on character card": "在角色卡上隐藏",
    +    "tag_entries": "标签条目",
         "Delete tag": "删除标签",
    -    "Toggle entry's active state.": "切换条目激活状态。",
    -    "Entry Title/Memo": "条目标题/备忘录",
    -    "WI Entry Status:🔵 Constant🟢 Normal🔗 Vectorized": "世界书条目状态:\r🔵 永久\r🟢 关键词\r🔗 向量化",
    -    "WI_Entry_Status_Constant": "永久",
    -    "WI_Entry_Status_Normal": "关键词",
    -    "WI_Entry_Status_Vectorized": "向量化",
    -    "T_Position": "↑Char:在角色定义之前\n↓Char:在角色定义之后\n↑AN:在作者注释之前\n↓AN:在作者注释之后\n@D:在深度D处",
    -    "Before Char Defs": "角色定义之前",
    -    "After Char Defs": "角色定义之后",
    -    "Before EM": "↑EM",
    -    "After EM": "↓EM",
    -    "Before AN": "作者注释之前",
    -    "After AN": "作者注释之后",
    -    "at Depth System": "@D ⚙​​️",
    -    "at Depth User": "@D 👤",
    -    "at Depth AI": "@D 🤖",
    -    "Depth": "深度",
    -    "Order:": "顺序:",
    -    "Order": "顺序",
    -    "Trigger %:": "触发 %:",
    -    "Duplicate world info entry": "重复的世界信息条目",
    -    "Delete world info entry": "删除世界信息条目",
         "Comma separated (required)": "逗号分隔(必填)",
         "Primary Keywords": "主要关键字",
         "Keywords or Regexes": "关键字或正则表达式",
    @@ -1119,17 +1202,22 @@
         "Use global": "使用全局",
         "Yes": "是",
         "No": "否",
    -    "Whole Words": "Whole Words",
    -    "Group Scoring": "Group Scoring",
    +    "Whole Words": "完整单词",
    +    "Group Scoring": "组评分",
         "Can be used to automatically activate Quick Replies": "可用于自动激活快速回复",
         "Automation ID": "自动化ID",
         "( None )": "(没有任何)",
    -    "delay_until_recursion_level": "Defines delay levels for recursive scans.\r\rInitially, only the first level (smallest number) will match.\rOnce no matches are found, the next level becomes eligible for matching.\rThis repeats until all levels are checked.\r\rTied to the \"Delay until recursion\" setting.",
    +    "delay_until_recursion_level": "定义递归扫描的延迟级别。\n最初,只有第一级(最小数字)会匹配。\n一旦未找到匹配项,下一个级别将变为匹配的候选项。\n这将重复,直到检查所有级别。\n与“延迟到递归”设置相关联。",
         "Recursion Level": "递归等级",
         "Content": "内容",
    +    "This entry will not be recursively activated by other entries.": "此条目不会被其他条目递归激活。",
         "Non-recursable": "不可递归(不会被其他条目激活)",
    +    "This entry will not activate other entries recursively.": "此条目不会被其他条目激活。",
         "Prevent further recursion": "防止进一步递归",
    +    "This entry can only be activated on recursive checking.": "此条目只能在递归检查时被激活。",
         "Delay until recursion": "延迟到递归",
    +    "This entry will be included ignoring budget constraints, assuming all other checks pass.": "只要其他检查通过,此条目将无视回复限额,直接加入提示词中",
    +    "Ignore budget": "无视回复限额",
         "What this keyword should mean to the AI, sent verbatim": "这个关键词对AI的含义,逐字发送",
         "Inclusion Group": "包含组",
         "Inclusion Groups ensure only one entry from a group is activated at a time, if multiple are triggered.Documentation: World Info - Inclusion Group": "包含组可确保每次仅激活组中的一项(如果触发了多项)。支持多个逗号分隔的组。文档:世界信息 - 包含组",
    @@ -1151,28 +1239,66 @@
         "Switch the Character/Tags filter around to exclude the listed characters and tags from matching for this entry": "切换角色/标签筛选方式,将列出的角色和标签排除在匹配范围之外",
         "Exclude": "排除",
         "-- Characters not found --": "-- 未找到角色 --",
    +    "Filter to Generation Triggers": "筛选生成触发器",
    +    "Continue": "继续",
    +    "Impersonate": "AI 帮答",
    +    "Swipe": "滑动",
    +    "Regenerate": "重新生成",
    +    "Quiet": "静默",
         "Selective": "选择性",
         "Use Probability": "使用概率",
         "Add Memo": "添加备忘录",
    +    "Additional Matching Sources": "额外匹配来源",
    +    "Character Personality": "角色性格",
    +    "Toggle entry's active state.": "切换条目激活状态。",
    +    "Entry Title/Memo": "条目标题/备忘录",
    +    "WI Entry Status:🔵 Constant🟢 Normal🔗 Vectorized": "世界书条目状态:\r🔵 永久\r🟢 关键词\r🔗 向量化",
    +    "WI_Entry_Status_Constant": "永久",
    +    "WI_Entry_Status_Normal": "关键词",
    +    "WI_Entry_Status_Vectorized": "向量化",
    +    "T_Position": "↑Char:在角色定义之前\n↓Char:在角色定义之后\n↑AN:在作者注释之前\n↓AN:在作者注释之后\n@D:在深度D处",
    +    "Before Char Defs": "角色定义之前",
    +    "After Char Defs": "角色定义之后",
    +    "Before EM": "示例消息前(↑EM)",
    +    "After EM": "示例消息后(↓EM)",
    +    "Before AN": "作者注释之前",
    +    "After AN": "作者注释之后",
    +    "at Depth System": "@D ⚙ [系统]在深度​​️",
    +    "at Depth User": "@D 👤 [用户]在深度",
    +    "at Depth AI": "@D 🤖 [AI]在深度",
    +    "Depth": "深度",
    +    "Order:": "顺序:",
    +    "Order": "顺序",
    +    "Trigger %:": "触发 %:",
    +    "Move/Copy Entry to Another Lorebook": "移动或复制条目到其他世界书",
    +    "Duplicate world info entry": "重复的世界信息条目",
    +    "Delete world info entry": "删除世界信息条目",
    +    "This character will be used as a welcome page assistant.": "此角色将作为欢迎页面助理。",
         "Text or token ids": "文本或 [token ID]",
         "Type here...": "在此处输入...",
         "close": "关闭",
         "prompt_manager_edit": "编辑",
         "prompt_manager_name": "姓名",
         "A name for this prompt.": "此提示词的名称。",
    -    "To whom this message will be attributed.": "此消息应归于谁。",
         "AI Assistant": "AI助手",
    +    "To whom this message will be attributed.": "此消息应归于谁。",
    +    "Triggers": "触发器",
    +    "Filter to specific generation types.": "筛选到特定的生成类型。",
         "prompt_manager_position": "位置",
    -    "Relative (to other prompts in prompt manager) or In-chat @ Depth.": "相对(相对于提示管理器中的其他提示)或在聊天中@深度。",
         "prompt_manager_relative": "相对",
         "prompt_manager_in_chat": "聊天中",
    +    "Relative (to other prompts in prompt manager) or In-chat @ Depth.": "相对(相对于提示管理器中的其他提示)或在聊天中@深度。",
         "prompt_manager_depth": "深度",
         "0 = after the last message, 1 = before the last message, etc.": "“0”为在最后一条消息之后,“1”为在最后一条消息之前,等等。",
    +    "prompt_manager_order": "排序",
    +    "prompt_manager_order_note": "来自其他来源(世界信息、作者注释等)的提示注入的默认顺序为 100。",
    +    "Ordered from low/top to high/bottom, and at same order: Assistant, User, System.": "从低/顶到高/底排序,并按相同顺序:助手、用户、系统。",
         "The content of this prompt is pulled from elsewhere and cannot be edited here.": "此提示词的内容是从其他地方提取的,无法在此处进行编辑。",
         "Prompt": "提示词",
    -    "The prompt to be sent.": "要发送的提示词。",
         "This prompt cannot be overridden by character cards, even if overrides are preferred.": "即使选择覆盖,此提示词也不能被角色卡覆盖。",
         "prompt_manager_forbid_overrides": "禁止覆盖",
    +    "Source:": "来源:",
    +    "The prompt to be sent.": "要发送的提示词。",
         "reset": "重置",
         "save": "保存",
         "This message is invisible for the AI": "此消息对AI不可见",
    @@ -1197,13 +1323,14 @@
         "Thought for some time": "思考了一会",
         "Confirm Edit": "确认",
         "Remove reasoning": "删除推理内容",
    -    "Cancel edit": "Cancel edit",
    +    "Cancel edit": "取消编辑",
    +    "Collapse all reasoning blocks": "折叠所有推理块",
         "Copy reasoning": "复制推理内容",
         "Edit reasoning": "编辑推理内容",
    -    "Enlarge": "放大",
    +    "Expand and zoom": "展开并缩放",
         "Caption": "标题",
    -    "Swipe left": "Swipe left",
    -    "Swipe right": "Swipe right",
    +    "Swipe left": "向左滑动",
    +    "Swipe right": "向右滑动",
         "Welcome to SillyTavern!": "欢迎来到 SillyTavern!",
         "SillyTavern is aimed at advanced users.": "SillyTavern 面向高级用户。",
         "welcome_message_part_1": "阅读",
    @@ -1218,11 +1345,11 @@
         "onboarding_import": "导入",
         "from supported sources or view": "来自受支持的来源或查看",
         "Sample characters": "示例角色",
    -    "Your Persona": "您的用户角色",
    -    "Before you get started, you must select a persona name.": "在开始之前,您必须选择一个用户角色名称。",
    +    "Your Persona": "您的用户设定",
    +    "Before you get started, you must select a persona name.": "在开始之前,您必须起一个用户名。",
         "welcome_message_part_8": "您可随时通过",
         "welcome_message_part_9": "图标来更改此设置。",
    -    "Persona Name:": "用户角色名称:",
    +    "Persona Name:": "用户设定名称:",
         "Temporarily disable automatic replies from this character": "临时禁言此角色",
         "Enable automatic replies from this character": "解除禁言此角色",
         "Trigger a message from this character": "强制触发该角色发言",
    @@ -1231,6 +1358,8 @@
         "View character card": "查看角色卡片",
         "Remove from group": "踢出群聊",
         "Add to group": "拉入群聊",
    +    "in this group": "在此群组中",
    +    "Go back": "返回",
         "Alternate Greetings": "额外问候语",
         "Alternate_Greetings_desc": "开始新聊天时,这些按钮将显示为第一条消息的滑动选项。\n群成员可以选择其中之一来发起对话。",
         "alternate_greetings_hint_1": "点击",
    @@ -1295,9 +1424,6 @@
         "Start new chat": "开始新聊天",
         "Manage chat files": "管理聊天文件",
         "Delete messages": "删除消息",
    -    "Regenerate": "重新生成",
    -    "Impersonate": "AI 帮答",
    -    "Continue": "继续",
         "extension_install_1": "若想从此页安装扩展程序,你需要提前安装",
         "extension_install_2": "。",
         "extension_install_3": "点这个图标(",
    @@ -1314,6 +1440,7 @@
         "Load an asset list": "加载资产列表",
         "Load Asset List": "加载资产列表",
         "Characters": "人物",
    +    "Attach a file or image to a current chat.": "将文件或图像附加到当前聊天中。",
         "Attach a File": "附加文件",
         "Enter a URL or the ID of a Fandom wiki page to scrape:": "输入要抓取的 Fandom wiki 页面的 URL 或 ID:",
         "Examples:": "例:",
    @@ -1327,12 +1454,12 @@
         "These files will be available for extensions that support attachments (e.g. Vector Storage).": "这些文件将可用于支持附件的扩展(例如 Vector Storage)。",
         "Supported file types: Plain Text, PDF, Markdown, HTML, EPUB.": "支持的文件类型:纯文本、PDF、Markdown、HTML、EPUB。",
         "Drag and drop files here to upload.": "将文件拖放到此处进行上传。",
    -    "Date (Newest First)": "日期(最新日期)",
    -    "Date (Oldest First)": "日期(最早日期)",
    +    "Date (Newest First)": "日期(最新在先)",
    +    "Date (Oldest First)": "日期(最老在先)",
         "Name (A-Z)": "姓名(从 A 到 Z)",
    -    "Name (Z-A)": "姓名 (Z-A)",
    -    "Size (Smallest First)": "尺寸(最小)",
    -    "Size (Largest First)": "尺寸(最大尺寸优先)",
    +    "Name (Z-A)": "姓名(从 Z 到 A)",
    +    "Size (Smallest First)": "大小(最小在先)",
    +    "Size (Largest First)": "大小(最大在先)",
         "Bulk Edit": "批量编辑",
         "Select All": "全选",
         "Select None": "清空选择",
    @@ -1360,16 +1487,21 @@
         "Model": "模型",
         "currently_selected": "[当前选定]",
         "currently_loaded": "[当前正在加载]",
    +    "Custom Model Tag": "自定义模型标签",
    +    "(for [Custom model] option)": "(自定义模型选项用)",
         "Allow reverse proxy": "允许反向代理",
         "Hint:": "提示:",
         "Set your API keys and endpoints in the 'API Connections' tab first.": "首先在“API 连接”选项卡中设置您的 API 密钥和端点。",
    +    "Use secondary URL": "使用备用URL",
    +    "Secondary captioning endpoint URL": "备用的描述文字生成端点URL",
         "Caption Prompt": "图像描述提示词",
         "Ask every time": "每次都询问",
         "Message Template": "消息模板",
         "(use _space": "(使用",
         "macro)": "宏指令)",
    -    "Automatically caption images": "自动为图像添加标题",
    -    "Edit captions before saving": "保存前编辑标题",
    +    "Automatically caption images": "自动为图像添加描述文字",
    +    "Edit captions before saving": "保存前编辑描述文字",
    +    "Show captions in chat": "在聊天中显示描述文字",
         "Included settings:": "包含的设置:",
         "{{@key}}": "{{@key}}:",
         "Profile name:": "配置名称:",
    @@ -1394,9 +1526,14 @@
         "Classifier API": "分类器 API",
         "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
         "Main API": "当前连接的 API",
    -    "WebLLM Extension": "WebLLM Extension",
    +    "WebLLM Extension": "WebLLM 扩展程序",
    +    "When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.": "When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.",
    +    "Filter expressions for available sprites": "Filter expressions for available sprites",
         "LLM Prompt": "大语言模型提示词",
    -    "Will be used if the API doesn't support JSON schemas or function calling.": "如果 API 不支持 JSON 模式或函数调用,则会使用它。",
    +    "Used in addition to JSON schemas and function calling.": "Used in addition to JSON schemas and function calling.",
    +    "LLM Prompt Strategy": "LLM Prompt Strategy",
    +    "Limited Context": "Limited Context",
    +    "Full Context": "Full Context",
         "Default / Fallback Expression": "默认/后备表达式",
         "Set the default and fallback expression being used when no matching expression is found.": "设置在未找到匹配表达式时使用的默认表达式和后备表达式。",
         "Custom Expressions": "自定义表达式",
    @@ -1422,29 +1559,29 @@
         "ext_sum_with": "总结如下:",
         "ext_sum_main_api": "主要 API",
         "ext_sum_webllm": "WebLLM 扩展",
    -    "ext_sum_current_summary": "当前摘要:",
    -    "ext_sum_restore_tip": "恢复先前的摘要;重复使用以清除此聊天的摘要状态",
    +    "ext_sum_current_summary": "当前总结:",
    +    "ext_sum_restore_tip": "恢复先前的总结;重复使用以清除此聊天的总结状态",
         "ext_sum_restore_previous": "恢复上一个",
    -    "ext_sum_memory_placeholder": "摘要将在这里生成...",
    -    "ext_sum_force_tip": "立即触发摘要更新。",
    -    "ext_sum_force_text": "现在总结",
    -    "Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API).": "禁用自动摘要更新。暂停时,摘要保持原样。您仍然可以通过按“立即汇总”按钮(仅适用于主 API)强制更新。",
    +    "ext_sum_memory_placeholder": "总结将在这里生成...",
    +    "ext_sum_force_tip": "立即触发总结。",
    +    "ext_sum_force_text": "立即总结",
    +    "Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API).": "禁用自动总结。暂停时,总结保持原样。您仍然可以通过按“立即总结”按钮(仅适用于主 API)强制更新。",
         "ext_sum_pause": "暂停",
         "Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN.": "从要总结的文本中省略世界信息和作者注释。仅在使用主 API 时有效。附加 API 始终省略世界书/作者注。",
         "ext_sum_no_wi_an": "无世界书/作者注",
    -    "ext_sum_settings_tip": "编辑摘要提示词、插入位置等。",
    -    "ext_sum_settings": "摘要设置",
    +    "ext_sum_settings_tip": "编辑总结提示词、插入位置等。",
    +    "ext_sum_settings": "总结设置",
         "ext_sum_prompt_builder": "提示词生成器",
    -    "ext_sum_prompt_builder_1_desc": "扩展将使用尚未汇总的消息构建自己的提示词。阻止聊天,直到生成摘要为止。",
    +    "ext_sum_prompt_builder_1_desc": "将使用尚未被总结的消息构建提示词,在总结生成前暂停聊天。",
         "ext_sum_prompt_builder_1": "原始,阻塞",
    -    "ext_sum_prompt_builder_2_desc": "扩展将使用尚未汇总的消息构建自己的提示词。在生成摘要时不会阻止聊天。并非所有后端都支持此模式。",
    +    "ext_sum_prompt_builder_2_desc": "将使用尚未被总结的消息构建提示词。在生成总结时不会暂停聊天。并非所有后端都支持此模式。",
         "ext_sum_prompt_builder_2": "原始,非阻塞",
    -    "ext_sum_prompt_builder_3_desc": "扩展将使用常规主提示词生成器并将摘要请求添加为其作为最后的系统消息。",
    +    "ext_sum_prompt_builder_3_desc": "将使用常规主提示词生成器,并将总结请求设为系统消息添加到提示词最后。",
         "ext_sum_prompt_builder_3": "经典,阻塞",
    -    "Summary Prompt": "摘要提示词",
    +    "Summary Prompt": "总结提示词",
         "ext_sum_restore_default_prompt_tip": "恢复默认提示词",
    -    "ext_sum_prompt_placeholder": "该提示词将被发送给 AI,以请求生成摘要。{{words}} 将解析为“字数”参数。",
    -    "ext_sum_target_length_1": "目标摘要长度",
    +    "ext_sum_prompt_placeholder": "该提示词将被发送给 AI,以请求生成总结。{{words}} 将解析为“字数”参数。",
    +    "ext_sum_target_length_1": "目标总结长度",
         "ext_sum_target_length_2": "(",
         "ext_sum_target_length_3": "字)",
         "ext_sum_api_response_length_1": "API 响应长度",
    @@ -1464,10 +1601,10 @@
         "ext_sum_injection_template": "插入模板",
         "ext_sum_memory_template_placeholder": "{{summary}} 将解析当前摘要内容。",
         "ext_sum_injection_position": "插入位置",
    -    "ext_sum_include_wi_scan_desc": "在 WI 扫描中包括最新摘要。",
    +    "ext_sum_include_wi_scan_desc": "在世界信息扫描中加入最新总结。",
         "ext_sum_include_wi_scan": "纳入世界信息扫描",
         "None (not injected)": "无(未注入)",
    -    "ext_sum_injection_position_none": "摘要不会被注入到提示中。您仍然可以通过 {{summary}} 宏访问它。",
    +    "ext_sum_injection_position_none": "总结不会被注入到提示中。您仍然可以通过 {{summary}} 宏访问它。",
         "How many messages before the current end of the chat.": "当前聊天结束前还有多少条消息。",
         "Labels and Message": "标签和信息",
         "Label": "标签",
    @@ -1489,6 +1626,7 @@
         "Execute on chat change": "聊天内容改变时执行",
         "Execute on new chat": "在新聊天中执行",
         "Execute on group member draft": "起草群组成员时执行",
    +    "Execute before message generation": "在消息生成前执行",
         "Automation ID:": "自动化标识",
         "Testing": "测试",
         "Execute": "执行",
    @@ -1498,6 +1636,8 @@
         "Show Popout Button": "(在电脑上)展示弹出式按钮",
         "Global Quick Reply Sets": "全局快速回复集",
         "Chat Quick Reply Sets": "聊天快速回复集",
    +    "Character Quick Reply Sets": "角色快速回复集",
    +    "(Private)": "(私密)",
         "Edit Quick Replies": "编辑快速回复",
         "Disable Send (Insert Into Input Field)": "禁用发送(插入输入字段)",
         "Place Quick Reply Before Input": "在输入前放置快速回复",
    @@ -1506,21 +1646,44 @@
         "macro for manual injection)": "宏用于手动注入)",
         "Color": "颜色",
         "Only apply color as accent": "仅应用颜色作为强调",
    +    "ext_regex_new_global_script_desc": "新增「全局」正规表达式",
    +    "ext_regex_new_scoped_script_desc": "新增「局部」正规表达式",
    +    "ext_regex_debugger_active_rules": "激活的规则",
    +    "ext_regex_debugger_save_order": "保存此顺序",
    +    "ext_regex_debugger_testing_area": "测试区域",
    +    "ext_regex_debugger_raw_input": "原始输入",
    +    "ext_regex_debugger_run_test": "运行测试",
    +    "ext_regex_debugger_display_replace": "替换",
    +    "ext_regex_debugger_display_highlight": "高亮",
    +    "ext_regex_debugger_render_text": "渲染为文本",
    +    "ext_regex_debugger_render_message": "渲染为消息",
    +    "ext_regex_debugger_step_by_step": "逐步转换",
    +    "ext_regex_debugger_final_output": "最终输出",
         "ext_regex_title": "正则",
    -    "ext_regex_new_global_script_desc": "新的全局正则表达式脚本",
    +    "ext_regex_presets": "正则预设",
    +    "ext_regex_presets_desc": "可以轻松保存并切换多组正则开关状态。",
    +    "ext_regex_preset_create": "创建新预设",
    +    "ext_regex_preset_update": "更新已有预设",
    +    "ext_regex_preset_apply": "重新应用当前预设",
    +    "ext_regex_preset_delete": "删除当前预设",
         "ext_regex_new_global_script": "新建全局正则",
    -    "ext_regex_new_scoped_script_desc": "新的作用域正则表达式脚本",
         "ext_regex_new_scoped_script": "新建局部正则",
         "ext_regex_import_script": "导入正则",
    +    "ext_regex_bulk_edit": "批量编辑",
    +    "ext_regex_debugger_desc": "高级正则调试工具",
    +    "ext_regex_debugger": "调试工具",
    +    "Export": "导出",
         "ext_regex_global_scripts": "全局正则脚本",
         "ext_regex_global_scripts_desc": "影响所有角色,保存在本地设定中",
    +    "No scripts found": "没有找到脚本",
         "ext_regex_scoped_scripts": "局部正则脚本",
         "ext_regex_disallow_scoped": "不允许使用局部正则",
         "ext_regex_allow_scoped": "允许使用局部正则",
         "ext_regex_scoped_scripts_desc": "只影响当前角色,保存在角色卡片中",
         "Regex Editor": "正则表达式编辑器",
         "Test Mode": "测试模式",
         "ext_regex_desc": "“正则”是一个使用“正则表达式”来查找/替换字符串的工具。如果您想了解更多信息,请点击标题旁边的“?”。",
    +    "ext_regex_flags_help": "点击此处了解更多关于正则表达式修饰符的知识。",
         "Input": "输入",
         "ext_regex_test_input_placeholder": "在此输入...",
         "Output": "输出",
    @@ -1553,10 +1716,14 @@
         "Substitute (raw)": "替换(原始)",
         "Substitute (escaped)": "替换(转义)",
         "Ephemerality": "短暂",
    +    "ext_regex_other_options_desc": "默认情况下,正则脚本将直接地、不可逆转地修改聊天文件。\r启用下方任意一或多项可以避免对聊天文件的修改,但仍然修改特定项目。",
         "ext_regex_only_format_visual_desc": "正则仅在聊天页面生效,聊天文件内的内容不会被改变。",
         "Only Format Display": "仅格式显示",
         "ext_regex_only_format_prompt_desc": "聊天记录不会改变,只有在请求发送时(生成时)才会出现提示词。",
         "Only Format Prompt (?)": "仅格式提示词",
    +    "This character has embedded regex script(s).": "此角色含有内置正则脚本。",
    +    "Would you like to allow using them?": "你想要启用它们吗?",
    +    "If you want to do it later, select 'Regex' from the extensions menu.": "你可以稍后在扩展栏的 \"正则\" 区域管理它们。",
         "ext_regex_import_target": "导入至
    ... [truncated]
    
  • public/locales/zh-tw.json+2 0 modified
    @@ -1791,6 +1791,8 @@
         "Derive context size from backend": "從後端推導上下文大小",
         "Using a proxy that you're not running yourself is a risk to your data privacy.": "使用非自行管理的代理服務可能導致您的資料隱私外洩。",
         "Claude API Key": "Claude API 金鑰",
    +    "Electron Hub API Key": "Electron Hub API 金鑰",
    +    "Electron Hub Model": "Electron Hub 模型",
         "NanoGPT API Key": "NanoGPT API 金鑰",
         "NanoGPT Model": "NanoGPT 模型",
         "context_derived": "若可能,根據模型後設資料推導。",
    
  • public/login.html+1 1 modified
    @@ -84,7 +84,7 @@ <h3 id="discreetLoginPrompt">
         </div>
     
         <script src="lib/jquery-3.5.1.min.js"></script>
    -    <script src="scripts/login.js"></script>
    +    <script src="scripts/login.js" type="module"></script>
     </body>
     
     </html>
    
  • public/script.js+12 2 modified
    @@ -266,6 +266,7 @@ import { initDataMaid } from './scripts/data-maid.js';
     import { clearItemizedPrompts, deleteItemizedPrompts, findItemizedPromptSet, initItemizedPrompts, itemizedParams, itemizedPrompts, loadItemizedPrompts, promptItemize, replaceItemizedPromptText, saveItemizedPrompts } from './scripts/itemized-prompts.js';
     import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
     import { event_types, eventSource } from './scripts/events.js';
    +import { initAccessibility } from './scripts/a11y.js';
     
     // API OBJECT FOR EXTERNAL WIRING
     globalThis.SillyTavern = {
    @@ -687,6 +688,7 @@ async function firstLoadInit() {
         initCustomSelectedSamplers();
         initDataMaid();
         initItemizedPrompts();
    +    initAccessibility();
         addDebugFunctions();
         doDailyExtensionUpdatesCheck();
         await hideLoader();
    @@ -5237,7 +5239,13 @@ function extractImageFromData(data, { mainApi = null, chatCompletionSource = nul
                             return `data:${inlineData.mimeType};base64,${inlineData.data}`;
                         }
                     } break;
    -
    +                case chat_completion_sources.OPENROUTER: {
    +                    const imageUrl = data?.choices[0]?.message?.images?.find(x => x.type === 'image_url')?.image_url?.url;
    +                    if (isDataURL(imageUrl)) {
    +                        return imageUrl;
    +                    }
    +                    // TODO: Handle remote URLs
    +                }
                 }
             } break;
         }
    @@ -5361,6 +5369,8 @@ export function extractJsonFromData(data, { mainApi = null, chatCompletionSource
                     case chat_completion_sources.CUSTOM:
                     case chat_completion_sources.COHERE:
                     case chat_completion_sources.XAI:
    +                case chat_completion_sources.ELECTRONHUB:
    +                case chat_completion_sources.AZURE_OPENAI:
                     default:
                         result = tryParse(text);
                         break;
    @@ -10808,7 +10818,7 @@ jQuery(async function () {
                     }
                 } break;
                 case 'replace_update': {
    -                const confirm = await Popup.show.confirm('Replace Character', '<p>Choose a new character card to replace this character with.</p>All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.<br />Proceed?');
    +                const confirm = await Popup.show.confirm(t`Replace Character`, '<p>' + t`Choose a new character card to replace this character with.` + '</p>' + t`All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.` + '<br />' + t`Proceed?`);
                     if (confirm) {
                         async function uploadReplacementCard(e) {
                             const file = e.target.files[0];
    
  • public/scripts/a11y.js+116 0 added
    @@ -0,0 +1,116 @@
    +/**
    + * Shared module between login and main app.
    + * Be careful what you import!
    + */
    +
    +const buttonSelectors = [
    +    '.menu_button',
    +    '.right_menu_button',
    +    '.mes_button',
    +    '.drawer-icon',
    +    '.inline-drawer-icon',
    +    '.swipe_left',
    +    '.swipe_right',
    +    '.character_select',
    +    '.tags .tag',
    +    '.jg-menu .jg-button',
    +    '.bg_example .mobile-only-menu-toggle',
    +    '.paginationjs-pages li a',
    +].join(', ');
    +
    +const listSelectors = [
    +    '.options-content',
    +    '.list-group',
    +    '#rm_print_characters_block',
    +    '#rm_group_members',
    +    '#rm_group_add_members',
    +    '.tag_view_list_tags',
    +    '.secretKeyManagerList',
    +    '.recentChatList',
    +    '.dataMaidCategoryContent',
    +    '#userList',
    +    '.bg_list',
    +].join(', ');
    +
    +const listItemSelectors = [
    +    '.options-content .list-group-item',
    +    '.list-group .list-group-item',
    +    '#rm_print_characters_block .entity_block',
    +    '#rm_group_members .group_member',
    +    '#rm_group_add_members .group_member',
    +    '.tag_view_list_tags .tag_view_item',
    +    '.secretKeyManagerList .secretKeyManagerItem',
    +    '.recentChatList .recentChat',
    +    '.dataMaidCategoryContent .dataMaidItem',
    +    '#userList .userSelect',
    +    '.bg_list .bg_example',
    +].join(', ');
    +
    +const toolbarSelectors = [
    +    '.jg-menu',
    +].join(', ');
    +
    +/** @type {Record<string, (element: Element) => void>} */
    +const a11yRules = {
    +    [buttonSelectors]: (element) => {
    +        element.setAttribute('role', 'button');
    +    },
    +    [listSelectors]: (element) => {
    +        element.setAttribute('role', 'list');
    +    },
    +    [listItemSelectors]: (element) => {
    +        element.setAttribute('role', 'listitem');
    +    },
    +    [toolbarSelectors]: (element) => {
    +        element.setAttribute('role', 'toolbar');
    +    },
    +    '#toast-container .toast': (element) => {
    +        element.setAttribute('role', 'status');
    +    },
    +};
    +
    +/**
    + * Apply accessibility rules to an element.
    + * @param {Element} element Element to process.
    + */
    +function applyA11yRules(element) {
    +    try {
    +        for (const [selector, rule] of Object.entries(a11yRules)) {
    +            // Apply if the element directly matches the selector
    +            if (element.matches(selector)) {
    +                rule(element);
    +            }
    +            // Apply the rule to descendants
    +            element.querySelectorAll(selector).forEach(rule);
    +        }
    +    } catch (error) {
    +        console.error('Error applying accessibility rules to element:', element, error);
    +    }
    +}
    +
    +function setAccessibilityObserver() {
    +    // Apply for existing elements
    +    applyA11yRules(document.body);
    +
    +    // Setup observer for dynamic content
    +    const observer = new MutationObserver((mutationsList) => {
    +        for (const mutation of mutationsList) {
    +            if (mutation.type === 'childList') {
    +                for (const addedNode of mutation.addedNodes) {
    +                    if (addedNode instanceof Element && addedNode.nodeType === Node.ELEMENT_NODE) {
    +                        applyA11yRules(addedNode);
    +                    }
    +                }
    +            }
    +        }
    +    });
    +
    +    observer.observe(document.body, {
    +        childList: true,
    +        subtree: true,
    +    });
    +}
    +
    +export function initAccessibility() {
    +    setAccessibilityObserver();
    +}
    
  • public/scripts/backgrounds.js+51 12 modified
    @@ -265,9 +265,10 @@ async function onCopyToSystemBackgroundClick(e) {
      * It caches the thumbnail in local storage and returns a blob URL for the thumbnail.
      * If the thumbnail cannot be fetched, it returns a transparent PNG pixel as a fallback.
      * @param {string} bg Background URL
    + * @param {boolean} isCustom Is the background custom?
      * @returns {Promise<string>} Blob URL of the thumbnail
      */
    -async function getThumbnailFromStorage(bg) {
    +async function getThumbnailFromStorage(bg, isCustom) {
         const cachedBlobUrl = THUMBNAIL_BLOBS.get(bg);
         if (cachedBlobUrl) {
             return cachedBlobUrl;
    @@ -281,7 +282,8 @@ async function getThumbnailFromStorage(bg) {
         }
     
         try {
    -        const response = await fetch(getBackgroundPath(bg), { cache: 'force-cache' });
    +        const url = isCustom ? bg : getBackgroundPath(bg);
    +        const response = await fetch(url, { cache: 'force-cache' });
             if (!response.ok) {
                 throw new Error('Fetch failed with status: ' + response.status);
             }
    @@ -519,7 +521,7 @@ async function resolveImageUrl(bg, isCustom) {
         const fileExtension = bg.split('.').pop().toLowerCase();
         const isAnimated = ['mp4', 'webp'].includes(fileExtension);
         const thumbnailUrl = isAnimated && !background_settings.animation
    -        ? await getThumbnailFromStorage(bg)
    +        ? await getThumbnailFromStorage(bg, isCustom)
             : isCustom
                 ? bg
                 : getThumbnailUrl('bg', bg);
    @@ -573,14 +575,21 @@ async function delBackground(bg) {
     }
     
     async function onBackgroundUploadSelected() {
    -    const form = $('#form_bg_download').get(0);
    +    const form = $('#form_bg_upload').get(0);
     
         if (!(form instanceof HTMLFormElement)) {
    -        console.error('form_bg_download is not a form');
    +        console.error('form_bg_upload is not a form');
             return;
         }
     
         const formData = new FormData(form);
    +
    +    const file = formData.get('avatar');
    +    if (!(file instanceof File) || file.size === 0) {
    +        form.reset();
    +        return;
    +    }
    +
         await convertFileIfVideo(formData);
         await uploadBackground(formData);
         form.reset();
    @@ -614,7 +623,7 @@ async function convertFileIfVideo(formData) {
             const sourceBuffer = await file.arrayBuffer();
             const convertedBuffer = await globalThis.convertVideoToAnimatedWebp({ buffer: new Uint8Array(sourceBuffer), name: file.name });
             const convertedFileName = file.name.replace(/\.[^/.]+$/, '.webp');
    -        const convertedFile = new File([convertedBuffer], convertedFileName, { type: 'image/webp' });
    +        const convertedFile = new File([new Uint8Array(convertedBuffer)], convertedFileName, { type: 'image/webp' });
             formData.set('avatar', convertedFile);
             toastMessage.remove();
         } catch (error) {
    @@ -693,12 +702,42 @@ function onBackgroundFilterInput() {
     export function initBackgrounds() {
         eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
         eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
    -    $(document).on('click', '.bg_example', onSelectBackgroundClick);
    -    $(document).on('click', '.bg_example_lock', onLockBackgroundClick);
    -    $(document).on('click', '.bg_example_unlock', onUnlockBackgroundClick);
    -    $(document).on('click', '.bg_example_edit', onRenameBackgroundClick);
    -    $(document).on('click', '.bg_example_cross', onDeleteBackgroundClick);
    -    $(document).on('click', '.bg_example_copy', onCopyToSystemBackgroundClick);
    +
    +    $(document)
    +        .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
    +        .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {
    +            e.stopPropagation();
    +            const $context = $(this).closest('.bg_example');
    +            const wasOpen = $context.hasClass('mobile-menu-open');
    +            // Close all other open menus before opening a new one.
    +            $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
    +            if (!wasOpen) {
    +                $context.addClass('mobile-menu-open');
    +            }
    +        })
    +        .off('click', '.jg-button').on('click', '.jg-button', function (e) {
    +            e.stopPropagation();
    +            const action = $(this).data('action');
    +
    +            switch (action) {
    +                case 'lock':
    +                    onLockBackgroundClick.call(this, e.originalEvent);
    +                    break;
    +                case 'unlock':
    +                    onUnlockBackgroundClick.call(this, e.originalEvent);
    +                    break;
    +                case 'edit':
    +                    onRenameBackgroundClick.call(this, e.originalEvent);
    +                    break;
    +                case 'delete':
    +                    onDeleteBackgroundClick.call(this, e.originalEvent);
    +                    break;
    +                case 'copy':
    +                    onCopyToSystemBackgroundClick.call(this, e.originalEvent);
    +                    break;
    +            }
    +        });
    +
         $('#auto_background').on('click', autoBackgroundCommand);
         $('#add_bg_button').on('change', onBackgroundUploadSelected);
         $('#bg-filter').on('input', onBackgroundFilterInput);
    
  • public/scripts/chats.js+28 1 modified
    @@ -1758,7 +1758,34 @@ export function addDOMPurifyHooks() {
     
             // Replace line breaks with <br> in unknown elements
             if (node instanceof HTMLUnknownElement) {
    -            node.innerHTML = node.innerHTML.trim().replaceAll('\n', '<br>');
    +            node.innerHTML = node.innerHTML.trim();
    +
    +            /** @type {Text[]} */
    +            const candidates = [];
    +            const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
    +            while (walker.nextNode()) {
    +                const textNode = /** @type {Text} */ (walker.currentNode);
    +                if (!textNode.data.includes('\n')) continue;
    +
    +                // Skip if this text node is within a <pre> (any ancestor)
    +                if (textNode.parentElement && textNode.parentElement.closest('pre')) continue;
    +
    +                candidates.push(textNode);
    +            }
    +
    +            for (const textNode of candidates) {
    +                const parts = textNode.data.split('\n');
    +                const frag = document.createDocumentFragment();
    +                parts.forEach((part, idx) => {
    +                    if (part.length) {
    +                        frag.appendChild(document.createTextNode(part));
    +                    }
    +                    if (idx < parts.length - 1) {
    +                        frag.appendChild(document.createElement('br'));
    +                    }
    +                });
    +                textNode.replaceWith(frag);
    +            }
             }
     
             const isMediaAllowed = isExternalMediaAllowed();
    
  • public/scripts/dynamic-styles.js+52 16 modified
    @@ -33,22 +33,37 @@ const observer = new MutationObserver(mutations => {
      * @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension
      */
     function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
    -    /** @type {{baseSelector: string, rule: CSSStyleRule}[]} */
    +    /** @typedef {{ type: 'media'|'supports'|'container', conditionText: string }} WrapperCond */
    +    /** @type {{baseSelector: string, rule: CSSStyleRule, wrappers: WrapperCond[]}[]} */
         const hoverRules = [];
         /** @type {Set<string>} */
         const focusRules = new Set();
     
         const PLACEHOLDER = ':__PLACEHOLDER__';
     
    +    /**
    +     * Builds a stable signature string for a chain of wrapper conditions so we can distinguish
    +     * identical selectors under different contexts (e.g., different @media queries)
    +     * @param {WrapperCond[]} wrappers
    +     * @returns {string}
    +     */
    +    function wrapperSignature(wrappers) {
    +        return wrappers.map(w => `${w.type}:${w.conditionText}`).join(';');
    +    }
    +
         /**
          * Processes the CSS rules and separates selectors for hover and focus
          * @param {CSSRuleList} rules - The CSS rules to process
    +     * @param {WrapperCond[]} wrappers - Current chain of wrapper conditions (@media/@supports/etc.)
          */
    -    function processRules(rules) {
    +    function processRules(rules, wrappers = []) {
             Array.from(rules).forEach(rule => {
                 if (rule instanceof CSSImportRule) {
                     // Make sure that @import rules are processed recursively
    -                processImportedStylesheet(rule.styleSheet);
    +                // If the @import has media conditions, treat them as wrappers as well
    +                /** @type {WrapperCond[]} */
    +                const extra = (rule.media && rule.media.mediaText) ? [{ type: 'media', conditionText: rule.media.mediaText }] : [];
    +                processImportedStylesheet(rule.styleSheet, [...wrappers, ...extra]);
                 } else if (rule instanceof CSSStyleRule) {
                     // Separate multiple selectors on a rule
                     const selectors = rule.selectorText.split(',').map(s => s.trim());
    @@ -60,39 +75,48 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
                             // We currently do nothing here. Rules containing both hover and focus are very specific and should never be automatically touched
                         }
                         else if (isHover) {
    -                        const baseSelector = selector.replace(':hover', PLACEHOLDER).trim();
    -                        hoverRules.push({ baseSelector, rule });
    +                        const baseSelector = selector.replace(/:hover/g, PLACEHOLDER).trim();
    +                        hoverRules.push({ baseSelector, rule, wrappers: [...wrappers] });
                         } else if (isFocus) {
                             // We need to make sure that we remember all existing :focus, :focus-within and :focus-visible rules
    -                        const baseSelector = selector.replace(':focus-within', PLACEHOLDER).replace(':focus-visible', PLACEHOLDER).replace(':focus', PLACEHOLDER).trim();
    -                        focusRules.add(baseSelector);
    +                        const baseSelector = selector.replace(/:focus(-within|-visible)?/g, PLACEHOLDER).trim();
    +                        focusRules.add(`${baseSelector}|${wrapperSignature(wrappers)}`);
                         }
                     });
    -            } else if (rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule) {
    -                // Recursively process nested rules
    -                processRules(rule.cssRules);
    +            } else if (rule instanceof CSSMediaRule) {
    +                // Recursively process nested @media rules
    +                processRules(rule.cssRules, [...wrappers, { type: 'media', conditionText: rule.conditionText }]);
    +            } else if (rule instanceof CSSSupportsRule) {
    +                // Recursively process nested @supports rules
    +                processRules(rule.cssRules, [...wrappers, { type: 'supports', conditionText: rule.conditionText }]);
    +            } else if (rule instanceof window.CSSContainerRule) {
    +                // Recursively process nested @container rules (if supported by the browser)
    +                // Note: conditionText contains the query like "(min-width: 300px)" or "style(color)"
    +                // Using 'container' as the type ensures uniqueness separate from @media/@supports
    +                processRules(rule.cssRules, [...wrappers, { type: 'container', conditionText: rule.conditionText }]);
                 }
             });
         }
     
         /**
          * Processes the CSS rules of an imported stylesheet recursively
          * @param {CSSStyleSheet} sheet - The imported stylesheet to process
    +     * @param {WrapperCond[]} wrappers - Wrapper conditions inherited from (at)import media
          */
    -    function processImportedStylesheet(sheet) {
    +    function processImportedStylesheet(sheet, wrappers = []) {
             if (sheet && sheet.cssRules) {
    -            processRules(sheet.cssRules);
    +            processRules(sheet.cssRules, wrappers);
             }
         }
     
    -    processRules(styleSheet.cssRules);
    +    processRules(styleSheet.cssRules, []);
     
         /** @type {CSSStyleSheet} */
         let targetStyleSheet = null;
     
         // Now finally create the dynamic focus rules
    -    hoverRules.forEach(({ baseSelector, rule }) => {
    -        if (!focusRules.has(baseSelector)) {
    +    hoverRules.forEach(({ baseSelector, rule, wrappers }) => {
    +        if (!focusRules.has(`${baseSelector}|${wrapperSignature(wrappers)}`)) {
                 // Only initialize the dynamic stylesheet if needed
                 targetStyleSheet ??= getDynamicStyleSheet({ fromExtension });
     
    @@ -103,7 +127,19 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
                 // If something like :focus-within or a more specific selector like `.blah:has(:focus-visible)` for elements inside,
                 // it should be manually defined in CSS.
                 const focusSelector = rule.selectorText.replace(/:hover/g, ':focus-visible');
    -            const focusRule = `${focusSelector} { ${rule.style.cssText} }`;
    +            let focusRule = `${focusSelector} { ${rule.style.cssText} }`;
    +
    +            // Wrap the generated rule into the same @media/@supports/@container chain (if any)
    +            if (wrappers.length > 0) {
    +                // Build nested blocks from outermost to innermost
    +                // Example: @media (x) { @supports (y) { <rule> } }
    +                focusRule = wrappers.reduceRight((inner, w) => {
    +                    if (w.type === 'media') return `@media ${w.conditionText} { ${inner} }`;
    +                    if (w.type === 'supports') return `@supports ${w.conditionText} { ${inner} }`;
    +                    if (w.type === 'container') return `@container ${w.conditionText} { ${inner} }`;
    +                    return inner;
    +                }, focusRule);
    +            }
     
                 try {
                     targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length);
    
  • public/scripts/extensions/caption/index.js+14 4 modified
    @@ -439,6 +439,8 @@ jQuery(async function () {
                             'cohere': SECRET_KEYS.COHERE,
                             'aimlapi': SECRET_KEYS.AIMLAPI,
                             'moonshot': SECRET_KEYS.MOONSHOT,
    +                        'nanogpt': SECRET_KEYS.NANOGPT,
    +                        'electronhub': SECRET_KEYS.ELECTRONHUB,
                         };
     
                         if (chatCompletionApis[api] && secret_state[chatCompletionApis[api]]) {
    @@ -543,8 +545,10 @@ jQuery(async function () {
             }
     
             await processEndpoint('openrouter', '/api/openrouter/models/multimodal');
    -        await processEndpoint('aimlapi', '/api/backends/chat-completions/aimlapi/models/multimodal');
    -        await processEndpoint('pollinations', '/api/backends/chat-completions/pollinations/models/multimodal');
    +        await processEndpoint('aimlapi', '/api/backends/chat-completions/multimodal-models/aimlapi');
    +        await processEndpoint('pollinations', '/api/backends/chat-completions/multimodal-models/pollinations');
    +        await processEndpoint('nanogpt', '/api/backends/chat-completions/multimodal-models/nanogpt');
    +        await processEndpoint('electronhub', '/api/backends/chat-completions/multimodal-models/electronhub');
         }
     
         await addSettings();
    @@ -588,10 +592,12 @@ jQuery(async function () {
             saveSettingsDebounced();
         });
         $('#caption_ollama_pull').on('click', (e) => {
    -        const presetModel = extension_settings.caption.multimodal_model !== 'ollama_current' ? extension_settings.caption.multimodal_model : '';
    +        const selectedModel = extension_settings.caption.multimodal_model;
    +        const staticModels = { 'ollama_current': textgenerationwebui_settings.ollama_model, 'ollama_custom': extension_settings.caption.ollama_custom_model };
    +        const presetModel = staticModels[selectedModel] || selectedModel;
             e.preventDefault();
             $('#ollama_download_model').trigger('click');
    -        $('#dialogue_popup_input').val(presetModel);
    +        $('.popup .popup-input').val(presetModel);
         });
         $('#caption_multimodal_api').on('change', async () => {
             const api = String($('#caption_multimodal_api').val());
    @@ -616,6 +622,10 @@ jQuery(async function () {
             extension_settings.caption.show_in_chat = !!$('#caption_show_in_chat').prop('checked');
             saveSettingsDebounced();
         });
    +    $('#caption_ollama_custom_model').val(extension_settings.caption.ollama_custom_model || '').on('input', () => {
    +        extension_settings.caption.ollama_custom_model = String($('#caption_ollama_custom_model').val()).trim();
    +        saveSettingsDebounced();
    +    });
     
         const onMessageEvent = async (index) => {
             if (!extension_settings.caption.auto_mode) {
    
  • public/scripts/extensions/caption/settings.html+28 7 modified
    @@ -21,13 +21,15 @@
                             <option value="anthropic">Anthropic</option>
                             <option value="cohere">Cohere</option>
                             <option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
    +                        <option value="electronhub">Electron Hub</option>
                             <option value="google">Google AI Studio</option>
                             <option value="vertexai">Google Vertex AI</option>
                             <option value="groq">Groq</option>
                             <option value="koboldcpp">KoboldCpp</option>
                             <option value="llamacpp">llama.cpp</option>
                             <option value="mistral">MistralAI</option>
                             <option value="moonshot">Moonshot AI</option>
    +                        <option value="nanogpt">NanoGPT</option>
                             <option value="ollama">Ollama</option>
                             <option value="openai">OpenAI</option>
                             <option value="openrouter">OpenRouter</option>
    @@ -40,7 +42,7 @@
                     <div class="flex1 flex-container flexFlowColumn flexNoGap">
                         <label for="caption_multimodal_model" data-i18n="Model">Model</label>
                         <select id="caption_multimodal_model" class="flex1 text_pole">
    -                        <!-- AI/ML API, OpenRouter, Pollinations are added externally by JavaScript -->
    +                        <!-- AI/ML API, OpenRouter, Pollinations, NanoGPT are added externally by JavaScript -->
                             <option data-type="cohere" value="c4ai-aya-vision-8b">c4ai-aya-vision-8b</option>
                             <option data-type="cohere" value="c4ai-aya-vision-32b">c4ai-aya-vision-32b</option>
                             <option data-type="cohere" value="command-a-vision-07-2025">command-a-vision-07-2025</option>
    @@ -110,6 +112,7 @@
                             <option data-type="google" value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
                             <option data-type="google" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
                             <option data-type="google" value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>
    +                        <option data-type="google" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
                             <option data-type="google" value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-pro-exp-03-25</option>
                             <option data-type="google" value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-pro-exp-03-25</option>
                             <option data-type="google" value="gemini-exp-1206">gemini-exp-1206 → 2.5-pro-exp-03-25</option>
    @@ -146,17 +149,26 @@
                             <option data-type="vertexai" value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
                             <option data-type="vertexai" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
                             <option data-type="vertexai" value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>
    +                        <option data-type="vertexai" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
                             <option data-type="vertexai" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
                             <option data-type="vertexai" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
    -                        <option data-type="groq" value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview</option>
    -                        <option data-type="groq" value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview</option>
    -                        <option data-type="groq" value="llava-v1.5-7b-4096-preview">llava-v1.5-7b-4096-preview</option>
    +                        <option data-type="groq" value="meta-llama/llama-4-scout-17b-16e-instruct">meta-llama/llama-4-scout-17b-16e-instruct</option>
    +                        <option data-type="groq" value="meta-llama/llama-4-maverick-17b-128e-instruct">meta-llama/llama-4-maverick-17b-128e-instruct</option>
                             <option data-type="ollama" value="ollama_current" data-i18n="currently_selected">[Currently selected]</option>
    +                        <option data-type="ollama" value="ollama_custom" data-i18n="[Custom model]">[Custom model]</option>
                             <option data-type="ollama" value="bakllava">bakllava</option>
                             <option data-type="ollama" value="llava">llava</option>
                             <option data-type="ollama" value="llava-llama3">llava-llama3</option>
                             <option data-type="ollama" value="llava-phi3">llava-phi3</option>
                             <option data-type="ollama" value="moondream">moondream</option>
    +                        <option data-type="ollama" value="gemma3">gemma3</option>
    +                        <option data-type="ollama" value="minicpm-v">minicpm-v</option>
    +                        <option data-type="ollama" value="qwen2.5vl">qwen2.5vl</option>
    +                        <option data-type="ollama" value="granite3.2-vision">granite3.2-vision</option>
    +                        <option data-type="ollama" value="mistral-small3.1">mistral-small3.1</option>
    +                        <option data-type="ollama" value="mistral-small3.2">mistral-small3.2</option>
    +                        <option data-type="ollama" value="llama3.2-vision">llama3.2-vision</option>
    +                        <option data-type="ollama" value="llama4">llama4</option>
                             <option data-type="llamacpp" value="llamacpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
                             <option data-type="ooba" value="ooba_current" data-i18n="currently_loaded">[Currently loaded]</option>
                             <option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
    @@ -168,16 +180,25 @@
                         </select>
                     </div>
                     <div data-type="ollama">
    -                    The model must be downloaded first! Do it with the <code>ollama pull</code> command or <a href="#" id="caption_ollama_pull">click here</a>.
    +                    <div>
    +                        The model must be downloaded first! Do it with the <code>ollama pull</code> command or <a href="#" id="caption_ollama_pull">click here</a>.
    +                    </div>
    +                    <div class="marginTop5">
    +                        <label for="caption_ollama_custom_model">
    +                            <span data-i18n="Custom Model Tag">Custom Model Tag</span>
    +                            <small data-i18n="(for [Custom model] option)">(for [Custom model] option)</small>
    +                        </label>
    +                        <input id="caption_ollama_custom_model" class="text_pole" type="text" placeholder="e.g. gemma3:latest" />
    +                    </div>
                     </div>
                     <label data-type="openai,anthropic,google,vertexai,mistral,xai" class="checkbox_label flexBasis100p" for="caption_allow_reverse_proxy" title="Allow using reverse proxy if defined and valid.">
                         <input id="caption_allow_reverse_proxy" type="checkbox" class="checkbox">
                         <span data-i18n="Allow reverse proxy">Allow reverse proxy</span>
                     </label>
    -                <div class="flexBasis100p m-b-1">
    +                <div class="flexBasis100p marginBot10">
                         <small><b data-i18n="Hint:">Hint:</b> <span data-i18n="Set your API keys and endpoints in the 'API Connections' tab first.">Set your API keys and endpoints in the 'API Connections' tab first.</span></small>
                     </div>
    -                <div data-type="koboldcpp,ollama,vllm,llamacpp,ooba" class="flex-container flexFlowColumn">
    +                <div data-type="koboldcpp,ollama,vllm,llamacpp,ooba" class="flex-container flexFlowColumn wide100p">
                         <label for="caption_altEndpoint_enabled" class="checkbox_label">
                             <input id="caption_altEndpoint_enabled" type="checkbox">
                             <span data-i18n="Use secondary URL">Use secondary URL</span>
    
  • public/scripts/extensions/connection-manager/index.js+11 0 modified
    @@ -43,6 +43,7 @@ const CC_COMMANDS = [
         'reasoning-template',
         'prompt-post-processing',
         'secret-id',
    +    'regex-preset',
     ];
     
     const TC_COMMANDS = [
    @@ -60,6 +61,7 @@ const TC_COMMANDS = [
         'start-reply-with',
         'reasoning-template',
         'secret-id',
    +    'regex-preset',
     ];
     
     const FANCY_NAMES = {
    @@ -79,6 +81,7 @@ const FANCY_NAMES = {
         'reasoning-template': 'Reasoning Template',
         'prompt-post-processing': 'Prompt Post-Processing',
         'secret-id': 'Secret',
    +    'regex-preset': 'Regex Preset',
     };
     
     /**
    @@ -357,6 +360,14 @@ function makeFancyProfile(profile) {
                 }
             }
     
    +        if (key === 'regex-preset') {
    +            const label = extension_settings.regex_presets?.find(p => p.id === profile[key])?.name;
    +            if (label) {
    +                acc[value] = label;
    +                return acc;
    +            }
    +        }
    +
             acc[value] = profile[key];
             return acc;
         }, {});
    
  • public/scripts/extensions/gallery/index.js+45 5 modified
    @@ -63,10 +63,10 @@ mutationObserver.observe(document.body, {
     });
     
     const SORT = Object.freeze({
    -    NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Sort By: Name (A-Z)` },
    -    NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Sort By: Name (Z-A)` },
    -    DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Sort By: Date (Oldest First)` },
    -    DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Sort By: Date (Newest First)` },
    +    NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Name (A-Z)` },
    +    NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Name (Z-A)` },
    +    DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Newest` },
    +    DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Oldest` },
     });
     
     const defaultSettings = Object.freeze({
    @@ -376,6 +376,11 @@ async function makeMovable(url) {
         const titleText = document.createElement('span');
         titleText.textContent = t`Image Gallery`;
         dragTitle.append(titleText);
    +
    +    // Create a container for the controls
    +    const controlsContainer = document.createElement('div');
    +    controlsContainer.classList.add('flex-container', 'alignItemsCenter');
    +
         const sortSelect = document.createElement('select');
         sortSelect.classList.add('gallery-sort-select');
     
    @@ -394,7 +399,42 @@ async function makeMovable(url) {
         });
     
         sortSelect.value = getSortOrder();
    -    dragTitle.append(sortSelect);
    +    controlsContainer.appendChild(sortSelect);
    +
    +    // Create the "Add Image" button
    +    const addImageButton = document.createElement('div');
    +    addImageButton.classList.add('menu_button', 'menu_button_icon', 'interactable');
    +    addImageButton.title = 'Add Image';
    +    addImageButton.innerHTML = '<i class="fa-solid fa-plus fa-fw"></i><div>Add Image</div>';
    +
    +    // Create a hidden file input
    +    const fileInput = document.createElement('input');
    +    fileInput.type = 'file';
    +    fileInput.accept = 'image/*';
    +    fileInput.multiple = true;
    +    fileInput.style.display = 'none';
    +
    +    // Trigger file input when the button is clicked
    +    addImageButton.addEventListener('click', () => {
    +        fileInput.click();
    +    });
    +
    +    // Handle file selection
    +    fileInput.addEventListener('change', async () => {
    +        const files = fileInput.files;
    +        if (files.length > 0) {
    +            for (const file of files) {
    +                await uploadFile(file, url);
    +            }
    +            // Refresh the gallery
    +            closeButton.trigger('click');
    +            await showCharGallery();
    +        }
    +    });
    +
    +    controlsContainer.appendChild(addImageButton);
    +    dragTitle.append(controlsContainer);
    +    newElement.append(fileInput); // Append hidden file input to the main element
     
         // add no-scrollbar class to this element
         newElement.addClass('no-scrollbar');
    
  • public/scripts/extensions/gallery/style.css+3 2 modified
    @@ -2,6 +2,7 @@
         display: flex;
         align-items: center;
         justify-content: center;
    +    gap: 4px;
     }
     
     .gallery-folder-input {
    @@ -26,13 +27,13 @@
         text-overflow: ellipsis;
         width: 100%;
         opacity: 0.8;
    -    background: none;
    +    background-color: var(--black30a);
    +    border: 1px solid var(--SmartThemeBorderColor);
         background-image: url(/img/down-arrow.svg);
         background-repeat: no-repeat;
         background-position: right 6px center;
         background-size: 8px 5px;
         padding-right: 20px;
    -    font-size: calc(var(--mainFontSize)* 0.9);
         margin-bottom: 0;
     }
     
    
  • public/scripts/extensions.js+2 0 modified
    @@ -188,6 +188,8 @@ export const extension_settings = {
         dice: {},
         /** @type {import('./char-data.js').RegexScriptData[]} */
         regex: [],
    +    /** @type {import('./extensions/regex/index.js').RegexPreset[]} */
    +    regex_presets: [],
         character_allowed_regex: [],
         tts: {},
         sd: {
    
  • public/scripts/extensions/regex/debugger.css+263 0 added
    @@ -0,0 +1,263 @@
    +/* Styles for the debugger UI */
    +#regex_debugger_rules {
    +    margin: 10px 0;
    +}
    +
    +#regex_debugger_rules,
    +#regex_debugger_rules .sortable-list {
    +    padding-left: 0;
    +}
    +
    +.regex-debugger-rules-list {
    +    position: relative;
    +}
    +
    +.regex-debugger-rule {
    +    display: flex;
    +    align-items: center;
    +    padding: 8px 10px;
    +    border: 1px solid var(--SmartThemeBorderColor);
    +    border-radius: 5px;
    +    cursor: pointer;
    +    background-color: var(--black30a);
    +    gap: 5px;
    +    margin-bottom: 5px;
    +}
    +
    +#regex_debugger_run_test_header {
    +    justify-content: space-between;
    +    align-items: center;
    +}
    +
    +#regex_debugger_expand_steps,
    +#regex_debugger_expand_final,
    +#regex_debugger_save_order {
    +    position: absolute;
    +    top: 0;
    +    right: 0;
    +    margin: 0;
    +}
    +
    +.regex-debugger-rule:hover {
    +    filter: brightness(1.1);
    +}
    +
    +.regex-debugger-rule .handle {
    +    cursor: grab;
    +    margin-right: 5px;
    +}
    +
    +.regex-debugger-rule .rule-details {
    +    flex-grow: 1;
    +    text-align: left;
    +    display: flex;
    +    align-items: baseline;
    +    gap: 5px;
    +}
    +
    +.regex-debugger-rule .rule-name {
    +    font-weight: bold;
    +}
    +
    +.regex-debugger-rule .rule-regex {
    +    font-size: 0.8em;
    +    opacity: 0.8;
    +    font-family: var(--monoFontFamily);
    +}
    +
    +.regex-debugger-rule .rule-scope {
    +    font-size: 0.8em;
    +    padding: 2px 6px;
    +    border-radius: 5px;
    +    background-color: var(--black30a);
    +    margin-left: auto;
    +    margin-right: 10px;
    +}
    +
    +.regex-debugger-rule .menu_button {
    +    margin: 0;
    +}
    +
    +#regex_debugger_raw_input {
    +    min-height: 1.8em;
    +}
    +
    +#regex_debugger_steps_output {
    +    min-height: 2em;
    +    max-height: 300px;
    +    overflow-y: auto;
    +    border: 1px solid var(--SmartThemeBorderColor);
    +    border-radius: 5px;
    +    padding: 5px;
    +    background-color: var(--black30a);
    +    font-family: var(--monoFontFamily);
    +    font-size: 0.9em;
    +}
    +
    +#regex_debugger_final_output {
    +    min-height: 2em;
    +    max-height: 300px;
    +    overflow-y: auto;
    +    border: 1px solid var(--SmartThemeBorderColor);
    +    border-radius: 5px;
    +    padding: 5px;
    +    background-color: var(--black30a);
    +    white-space: pre-wrap;
    +    word-break: break-word;
    +    text-align: left;
    +}
    +
    +.step-header {
    +    margin-top: 10px;
    +    margin-bottom: 5px;
    +}
    +
    +.step-output {
    +    white-space: pre-wrap;
    +    word-break: break-all;
    +    padding: 5px;
    +    background-color: var(--black30a);
    +    border-radius: 5px;
    +    text-align: left;
    +}
    +
    +/* Classes to replace inline styles */
    +.regex-debugger-no-rules {
    +    padding: 10px;
    +    text-align: center;
    +    opacity: 0.8;
    +}
    +
    +.regex-debugger-list-header {
    +    font-weight: bold;
    +    padding: 10px;
    +}
    +
    +/* Styles for statistics */
    +.step-header {
    +    display: flex;
    +    justify-content: space-between;
    +    align-items: center;
    +}
    +
    +.step-metrics {
    +    font-size: 0.8em;
    +    opacity: 0.8;
    +    font-weight: normal;
    +}
    +
    +.regex-debugger-summary {
    +    padding: 8px;
    +    margin-bottom: 10px;
    +    border: 1px solid var(--SmartThemeBorderColor);
    +    background-color: var(--black30a);
    +    border-radius: 5px;
    +    text-align: center;
    +    font-size: 0.9em;
    +}
    +
    +.regex-debugger-tester .results-header {
    +    position: relative;
    +    margin: 10px 0;
    +}
    +
    +.regex-debugger-tester .radio_group {
    +    text-align: left;
    +}
    +
    +/* Styles for statistics and highlighting additions */
    +.step-header {
    +    display: flex;
    +    justify-content: space-between;
    +    align-items: center;
    +    flex-wrap: wrap;
    +    /* Allow wrapping on small screens */
    +}
    +
    +.step-metrics {
    +    font-size: 0.8em;
    +    opacity: 0.8;
    +    font-weight: normal;
    +    white-space: nowrap;
    +    /* Prevent metrics from breaking line */
    +    margin-left: 10px;
    +}
    +
    +.regex-debugger-summary {
    +    padding: 8px;
    +    margin-bottom: 10px;
    +    border: 1px solid var(--SmartThemeBorderColor);
    +    background-color: var(--black30a);
    +    border-radius: 5px;
    +    text-align: center;
    +    font-size: 0.9em;
    +}
    +
    +/* New highlight color for added text */
    +mark.green_hl {
    +    background-color: #28a745;
    +    /* A standard green color */
    +    color: white;
    +}
    +
    +/* New highlight color for deleted text */
    +mark.red_hl {
    +    background-color: #dc3545;
    +    /* A standard red color */
    +    color: white;
    +    text-decoration: line-through;
    +}
    +
    +/* Styles for the expanded view with navigation */
    +.expanded-regex-container {
    +    display: flex;
    +    height: 75vh;
    +    /* Give the container a good height */
    +    overflow: hidden;
    +}
    +
    +.expanded-regex-nav {
    +    flex: 0 0 200px;
    +    /* Fixed width for the nav bar */
    +    border-right: 1px solid var(--SmartThemeBorderColor);
    +    padding: 5px;
    +    overflow-y: auto;
    +    background-color: var(--black30a);
    +}
    +
    +.expanded-regex-nav a {
    +    display: block;
    +    padding: 6px 8px;
    +    text-decoration: none;
    +    color: var(--SmartThemeMainColor);
    +    border-radius: 5px;
    +    white-space: nowrap;
    +    overflow: hidden;
    +    text-overflow: ellipsis;
    +}
    +
    +.expanded-regex-nav a:hover {
    +    background-color: var(--background_hover_color);
    +}
    +
    +.expanded-regex-nav a.active {
    +    background-color: var(--highlight_color);
    +    color: var(--text_color_black);
    +}
    +
    +.expanded-regex-content {
    +    flex-grow: 1;
    +    overflow-y: auto;
    +    padding-left: 10px;
    +}
    +
    +#regex_debugger_render_mode {
    +    padding-right: 20px;
    +    margin-top: 5px;
    +}
    +
    +.regex-popup-content {
    +    white-space: pre-wrap;
    +    word-break: break-all;
    +    text-align: left;
    +}
    
  • public/scripts/extensions/regex/debugger.html+175 0 added
    @@ -0,0 +1,175 @@
    +<div class="regex-debugger-container">
    +    <!-- Rules List Column -->
    +    <div class="regex-debugger-rules-list">
    +        <h3>
    +            <i class="fa-solid fa-list-ol"></i>
    +            <span data-i18n="ext_regex_debugger_active_rules"
    +                >Active Rules</span
    +            >
    +        </h3>
    +        <div class="flex-container">
    +            <button
    +                id="regex_debugger_save_order"
    +                class="menu_button menu_button_icon interactable"
    +                title="Save current rule order"
    +                tabindex="0"
    +            >
    +                <i class="fa-solid fa-floppy-disk"></i>
    +                <span data-i18n="ext_regex_debugger_save_order"
    +                    >Save Order</span
    +                >
    +            </button>
    +        </div>
    +        <ul id="regex_debugger_rules" class="sortable-list">
    +            <!-- Rules will be populated here by JavaScript -->
    +        </ul>
    +    </div>
    +
    +    <!-- Testing Area Column -->
    +    <div class="regex-debugger-tester">
    +        <h3>
    +            <i class="fa-solid fa-vial"></i>
    +            <span data-i18n="ext_regex_debugger_testing_area"
    +                >Testing Area</span
    +            >
    +        </h3>
    +        <div class="regex-debugger-io">
    +            <div class="regex-debugger-input">
    +                <label
    +                    for="regex_debugger_raw_input"
    +                    data-i18n="ext_regex_debugger_raw_input"
    +                    >Raw Input</label
    +                >
    +                <textarea
    +                    id="regex_debugger_raw_input"
    +                    class="text_pole autoSetHeight"
    +                    rows="4"
    +                ></textarea>
    +            </div>
    +            <div
    +                id="regex_debugger_run_test_header"
    +                class="flex-container"
    +            >
    +                <button
    +                    id="regex_debugger_run_test"
    +                    class="menu_button menu_button_icon interactable"
    +                    title="Run the test pipeline"
    +                    tabindex="0"
    +                >
    +                    <i class="fa-solid fa-play"></i>
    +                    <span data-i18n="ext_regex_debugger_run_test"
    +                        >Run Test</span
    +                    >
    +                </button>
    +                <div class="flex-container gap10px">
    +                    <div class="radio_group">
    +                        <label
    +                            ><input
    +                                type="radio"
    +                                name="display_mode"
    +                                value="replace"
    +                                checked
    +                            />
    +                            <span data-i18n="ext_regex_debugger_display_replace"
    +                                >Replace</span
    +                            ></label
    +                        >
    +                        <label
    +                            ><input
    +                                type="radio"
    +                                name="display_mode"
    +                                value="highlight"
    +                            />
    +                            <span
    +                                data-i18n="ext_regex_debugger_display_highlight"
    +                                >Highlight</span
    +                            ></label
    +                        >
    +                    </div>
    +                    <select
    +                        id="regex_debugger_render_mode"
    +                    >
    +                        <option
    +                            value="text"
    +                            data-i18n="ext_regex_debugger_render_text"
    +                        >
    +                            Render as Text
    +                        </option>
    +                        <option
    +                            value="message"
    +                            data-i18n="ext_regex_debugger_render_message"
    +                        >
    +                            Render as Message
    +                        </option>
    +                    </select>
    +                </div>
    +            </div>
    +            <div class="regex-debugger-results">
    +                <div class="results-header">
    +                    <h4>
    +                        <i class="fa-solid fa-shoe-prints"></i>
    +                        <span data-i18n="ext_regex_debugger_step_by_step"
    +                            >Step-by-step Transformation</span
    +                        >
    +                    </h4>
    +                    <div
    +                        id="regex_debugger_expand_steps"
    +                        class="menu_button menu_button_icon"
    +                        title="Expand view"
    +                    >
    +                        <i class="fa-solid fa-expand"></i>
    +                    </div>
    +                </div>
    +                <div id="regex_debugger_steps_output" class="results-box"></div>
    +
    +                <div class="results-header">
    +                    <h4>
    +                        <i class="fa-solid fa-flag-checkered"></i>
    +                        <span data-i18n="ext_regex_debugger_final_output"
    +                            >Final Output</span
    +                        >
    +                    </h4>
    +                    <div
    +                        id="regex_debugger_expand_final"
    +                        class="menu_button menu_button_icon"
    +                        title="Expand view"
    +                    >
    +                        <i class="fa-solid fa-expand"></i>
    +                    </div>
    +                </div>
    +                <div
    +                    id="regex_debugger_final_output"
    +                    class="results-box final-output"
    +                ></div>
    +            </div>
    +        </div>
    +    </div>
    +</div>
    +
    +<!-- Template for a single rule item -->
    +<template id="regex_debugger_rule_template">
    +    <li class="regex-debugger-rule" draggable="true">
    +        <i class="fa-solid fa-grip-vertical handle"></i>
    +        <label class="checkbox">
    +            <input type="checkbox" class="rule-enabled" checked />
    +        </label>
    +        <div class="rule-details">
    +            <span class="rule-name"></span>
    +            <code class="rule-regex"></code>
    +            <small class="rule-scope"></small>
    +        </div>
    +        <div class="menu_button menu_button_icon edit_rule" title="Edit Rule">
    +            <i class="fa-solid fa-pencil"></i>
    +        </div>
    +    </li>
    +</template>
    +
    +<!-- Template for a single transformation step -->
    +<template id="regex_debugger_step_template">
    +    <div class="step-result">
    +        <div class="step-header">
    +            <strong></strong>
    +        </div>
    +        <pre class="step-output"></pre>
    +    </div>
    +</template>
    
  • public/scripts/extensions/regex/dropdown.html+20 0 modified
    @@ -26,6 +26,10 @@
                         <i class="fa-solid fa-edit"></i>
                         <small data-i18n="ext_regex_bulk_edit">Bulk Edit</small>
                     </label>
    +                <div id="open_regex_debugger" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_debugger_desc" title="Advanced Regex Debugger">
    +                    <i class="fa-solid fa-bug-slash"></i>
    +                    <small data-i18n="ext_regex_debugger">Debugger</small>
    +                </div>
                 </div>
                 <div class="regex_bulk_operations flex-container justifyCenter">
                     <div id="bulk_select_all_toggle" class="menu_button menu_button_icon" title="Toggle Select All">
    @@ -49,6 +53,22 @@
                     </div>
                 </div>
                 <hr />
    +            <div id="regex_presets_block">
    +                <div class="flex-container alignItemsBaseline">
    +                    <strong class="flex1" data-i18n="ext_regex_presets">Regex Presets</strong>
    +                </div>
    +                <small data-i18n="ext_regex_presets_desc">
    +                    Save and switch between groups of enabled regex scripts.
    +                </small>
    +                <div class="flex-container marginTop5">
    +                    <select id="regex_presets" class="text_pole flex1"></select>
    +                    <div id="regex_preset_create" class="menu_button fa-solid fa-file-circle-plus" data-i18n="[title]ext_regex_preset_create" title="Create a new regex preset"></div>
    +                    <div id="regex_preset_update" class="menu_button fa-solid fa-save" data-i18n="[title]ext_regex_preset_update" title="Update existing regex preset"></div>
    +                    <div id="regex_preset_apply" class="menu_button fa-solid fa-recycle" data-i18n="[title]ext_regex_preset_apply" title="Re-apply current preset"></div>
    +                    <div id="regex_preset_delete" class="menu_button fa-solid fa-trash" data-i18n="[title]ext_regex_preset_delete" title="Delete current preset"></div>
    +                </div>
    +            </div>
    +            <hr />
                 <div id="global_scripts_block" class="padding5">
                     <div>
                         <strong data-i18n="ext_regex_global_scripts">Global Scripts</strong>
    
  • public/scripts/extensions/regex/index.js+905 6 modified
    @@ -1,13 +1,13 @@
    -import { characters, eventSource, event_types, getCurrentChatId, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';
    +import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';
     import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js';
     import { selected_group } from '../../group-chats.js';
    -import { callGenericPopup, POPUP_TYPE } from '../../popup.js';
    +import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
     import { SlashCommand } from '../../slash-commands/SlashCommand.js';
     import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
     import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
     import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
     import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
    -import { download, equalsIgnoreCaseAndAccents, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4 } from '../../utils.js';
    +import { download, equalsIgnoreCaseAndAccents, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4, escapeHtml } from '../../utils.js';
     import { regex_placement, runRegexScript, substitute_find_regex } from './engine.js';
     import { t } from '../../i18n.js';
     import { accountStorage } from '../../util/AccountStorage.js';
    @@ -18,6 +18,452 @@ const sanitizeFileName = name => name.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '
      * @typedef {import('../../char-data.js').RegexScriptData} RegexScript
      */
     
    +/**
    + * @typedef {object} RegexPresetItem
    + * @property {string} id - UUID of the regex script
    + */
    +
    +/**
    + * @typedef {object} RegexPreset
    + * @property {string} id - UUID of the preset
    + * @property {string} name - Name of the preset
    + * @property {boolean} isSelected - Whether the preset is currently selected
    + * @property {RegexPresetItem[]} global - The list of global preset items
    + * @property {RegexPresetItem[]} scoped - The list of scoped preset items
    + */
    +
    +/**
    + * @typedef {object} RegexPresetState
    + * @property {string[]} global - List of enabled global regex script IDs
    + * @property {string[]} scoped - List of enabled scoped regex script IDs
    + */
    +
    +class RegexPresetManager {
    +    /** @type {HTMLSelectElement} */
    +    presetSelect = null;
    +
    +    /** @type {HTMLElement} */
    +    presetCreateButton = null;
    +
    +    /** @type {HTMLElement} */
    +    presetUpdateButton = null;
    +
    +    /** @type {HTMLElement} */
    +    presetApplyButton = null;
    +
    +    /** @type {HTMLElement} */
    +    presetDeleteButton = null;
    +
    +    /** @type {string|null} */
    +    currentPresetId = null;
    +
    +    /** @type {RegexPresetState|null} */
    +    lastKnownState = null;
    +
    +    /**
    +     * Captures the current state of enabled regex scripts for change detection.
    +     * @returns {RegexPresetState} The current state object
    +     */
    +    captureCurrentState() {
    +        const globalScripts = this.regexListToPresetItems(extension_settings.regex) || [];
    +        const scopedScripts = this.regexListToPresetItems(characters[this_chid]?.data?.extensions?.regex_scripts) || [];
    +
    +        return {
    +            global: globalScripts.map(item => item.id).sort(),
    +            scoped: scopedScripts.map(item => item.id).sort(),
    +        };
    +    }
    +
    +    /**
    +     * Compares two state objects to detect changes.
    +     * @param {RegexPresetState} state1 First state object
    +     * @param {RegexPresetState} state2 Second state object
    +     * @returns {boolean} True if states are different
    +     */
    +    hasStateChanged(state1, state2) {
    +        if (!state1 || !state2) return false;
    +
    +        const global1 = state1.global || [];
    +        const global2 = state2.global || [];
    +        const scoped1 = state1.scoped || [];
    +        const scoped2 = state2.scoped || [];
    +
    +        if (global1.length !== global2.length || scoped1.length !== scoped2.length) {
    +            return true;
    +        }
    +
    +        return !global1.every(id => global2.includes(id)) ||
    +            !scoped1.every(id => scoped2.includes(id));
    +    }
    +
    +    /**
    +     * Updates the stored state after a preset is applied or saved.
    +     * @param {string} presetId - The current preset ID
    +     */
    +    updateStoredState(presetId) {
    +        this.currentPresetId = presetId;
    +        this.lastKnownState = this.captureCurrentState();
    +    }
    +
    +    /**
    +     * Checks if there are unsaved changes and shows a confirmation dialog.
    +     * @returns {Promise<boolean>} True if user wants to proceed without saving
    +     */
    +    async checkUnsavedChanges() {
    +        if (!this.currentPresetId || !this.lastKnownState) {
    +            return true; // No current preset or state to compare
    +        }
    +
    +        const currentState = this.captureCurrentState();
    +        if (!this.hasStateChanged(this.lastKnownState, currentState)) {
    +            return true; // No changes detected
    +        }
    +
    +        const currentPreset = extension_settings.regex_presets.find(p => p.id === this.currentPresetId);
    +        const presetName = currentPreset ? currentPreset.name : t`Unknown Preset`;
    +
    +        const choice = await Popup.show.confirm(
    +            t`You have unsaved changes to the "${presetName}" preset.`,
    +            t`Do you want to save them before switching?`,
    +            {
    +                okButton: t`Save Changes`,
    +                cancelButton: t`Discard Changes`,
    +            },
    +        );
    +
    +        if (choice) {
    +            // User chose to save changes
    +            await this.savePreset(this.currentPresetId, true);
    +            this.renderPresetList();
    +            return true;
    +        }
    +
    +        // User chose to discard changes
    +        return true;
    +    }
    +
    +    /**
    +     * Sets up event listeners for the preset management UI.
    +     * @returns {void}
    +     */
    +    setupEventListeners() {
    +        this.presetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('regex_presets'));
    +        if (!this.presetSelect) {
    +            console.error('RegexPresetManager: Could not find preset select element in the DOM.');
    +            return;
    +        }
    +
    +        this.presetSelect.addEventListener('change', async (event) => {
    +            const selectedPresetId = this.presetSelect.value;
    +            const fromSlashCommand = event instanceof CustomEvent && event?.detail?.fromSlashCommand === true;
    +
    +            // Check for unsaved changes before switching
    +            if (!fromSlashCommand) {
    +                const canProceed = await this.checkUnsavedChanges();
    +                if (!canProceed) {
    +                    // Revert the selection
    +                    event.preventDefault();
    +                    const currentPreset = extension_settings.regex_presets.find(p => p.id === this.currentPresetId);
    +                    if (currentPreset) {
    +                        this.presetSelect.value = currentPreset.id;
    +                    }
    +                    return;
    +                }
    +            }
    +
    +            await this.applyPreset(selectedPresetId);
    +            extension_settings.regex_presets.forEach(p => { p.isSelected = p.id === selectedPresetId; });
    +            saveSettingsDebounced();
    +            this.updateStoredState(selectedPresetId);
    +        });
    +
    +        this.presetCreateButton = document.getElementById('regex_preset_create');
    +        if (!this.presetCreateButton) {
    +            console.error('RegexPresetManager: Could not find preset create button in the DOM.');
    +            return;
    +        }
    +
    +        this.presetCreateButton.addEventListener('click', async () => {
    +            const newId = uuidv4();
    +            await this.savePreset(newId, false);
    +            this.renderPresetList();
    +            this.updateStoredState(newId);
    +        });
    +
    +        this.presetUpdateButton = document.getElementById('regex_preset_update');
    +        if (!this.presetUpdateButton) {
    +            console.error('RegexPresetManager: Could not find preset update button in the DOM.');
    +            return;
    +        }
    +
    +        this.presetUpdateButton.addEventListener('click', async () => {
    +            const selectedPresetId = this.presetSelect.value;
    +            await this.savePreset(selectedPresetId, true);
    +            this.renderPresetList();
    +            this.updateStoredState(selectedPresetId);
    +        });
    +
    +        this.presetApplyButton = document.getElementById('regex_preset_apply');
    +        if (!this.presetApplyButton) {
    +            console.error('RegexPresetManager: Could not find preset apply button in the DOM.');
    +            return;
    +        }
    +
    +        this.presetApplyButton.addEventListener('click', async () => {
    +            const selectedPresetId = this.presetSelect.value;
    +            await this.applyPreset(selectedPresetId);
    +            this.updateStoredState(selectedPresetId);
    +        });
    +
    +        this.presetDeleteButton = document.getElementById('regex_preset_delete');
    +        if (!this.presetDeleteButton) {
    +            console.error('RegexPresetManager: Could not find preset delete button in the DOM.');
    +            return;
    +        }
    +
    +        this.presetDeleteButton.addEventListener('click', async () => {
    +            const selectedPresetId = this.presetSelect.value;
    +            await this.deletePreset(selectedPresetId);
    +            this.renderPresetList();
    +
    +            const newSelectedPresetId = extension_settings.regex_presets.find(p => p.isSelected)?.id;
    +            if (newSelectedPresetId) {
    +                await this.applyPreset(newSelectedPresetId);
    +                this.presetSelect.value = newSelectedPresetId;
    +                this.updateStoredState(newSelectedPresetId);
    +            } else {
    +                this.currentPresetId = null;
    +                this.lastKnownState = null;
    +            }
    +        });
    +
    +        this.renderPresetList();
    +
    +        // Initialize the stored state with the currently selected preset
    +        const selectedPreset = extension_settings.regex_presets?.find(p => p.isSelected);
    +        if (selectedPreset) {
    +            this.updateStoredState(selectedPreset.id);
    +        }
    +    }
    +
    +    /**
    +     * Registers slash commands related to regex presets.
    +     * @returns {void}
    +     */
    +    registerSlashCommands() {
    +        SlashCommandParser.addCommandObject(SlashCommand.fromProps({
    +            name: 'regex-preset',
    +            helpString: t`Selects a regex preset by name or ID. Gets the current regex preset ID if no argument is provided.`,
    +            callback: (args, name) => {
    +                if (!this.presetSelect) {
    +                    return '';
    +                }
    +
    +                name = String(name ?? '').trim();
    +
    +                if (name) {
    +                    const quiet = isTrueBoolean(args?.quiet?.toString());
    +                    const foundId = extension_settings.regex_presets.find(p => equalsIgnoreCaseAndAccents(p.id, name) || equalsIgnoreCaseAndAccents(p.name, name))?.id;
    +
    +                    if (foundId) {
    +                        this.presetSelect.value = foundId;
    +                        this.presetSelect.dispatchEvent(new CustomEvent('change', { detail: { fromSlashCommand: true } }));
    +                        return foundId;
    +                    }
    +
    +                    !quiet && toastr.warning(`Regex preset "${name}" not found`);
    +                    return '';
    +                }
    +
    +                return this.presetSelect.value;
    +            },
    +            returns: 'current preset ID',
    +            namedArgumentList: [
    +                SlashCommandNamedArgument.fromProps({
    +                    name: 'quiet',
    +                    description: 'Suppress the toast message on preset change',
    +                    typeList: [ARGUMENT_TYPE.BOOLEAN],
    +                    defaultValue: 'false',
    +                    enumList: commonEnumProviders.boolean('trueFalse')(),
    +                }),
    +            ],
    +            unnamedArgumentList: [
    +                SlashCommandArgument.fromProps({
    +                    description: 'regex preset name or ID',
    +                    typeList: [ARGUMENT_TYPE.STRING],
    +                    enumProvider: () => extension_settings.regex_presets.map(x => new SlashCommandEnumValue(x.id, x.name, enumTypes.enum, enumIcons.preset)),
    +                }),
    +            ],
    +        }));
    +    }
    +
    +    /**
    +     * Renders the list of regex presets in the UI.
    +     * @returns {void}
    +     */
    +    renderPresetList() {
    +        if (!this.presetSelect) {
    +            return;
    +        }
    +
    +        this.presetSelect.innerHTML = '';
    +
    +        if (!Array.isArray(extension_settings.regex_presets) || extension_settings.regex_presets.length === 0) {
    +            const fallbackOption = new Option(t`[No presets saved]`, '', true, true);
    +            this.presetSelect.appendChild(fallbackOption);
    +            this.presetSelect.disabled = true;
    +            return;
    +        }
    +
    +        extension_settings.regex_presets.forEach(preset => {
    +            const option = new Option(preset.name, preset.id, preset.isSelected, preset.isSelected);
    +            this.presetSelect.appendChild(option);
    +        });
    +
    +        this.presetSelect.disabled = false;
    +    }
    +
    +    /**
    +     * Applies a preset list to a target list of scripts.
    +     * @param {Object} params The parameters object
    +     * @param {RegexPresetItem[]} params.presetList The list of preset items
    +     * @param {RegexScript[]} params.targetList The list of target scripts to modify
    +     * @param {(targetList: RegexScript[]) => Promise<any>} params.saveFunction Function to save the modified list
    +     */
    +    async applyPresetList({ presetList, targetList, saveFunction }) {
    +        if (!Array.isArray(targetList) || !Array.isArray(presetList)) {
    +            return;
    +        }
    +
    +        // Only enable scripts that are in the preset
    +        targetList.forEach((script => {
    +            script.disabled = !presetList.some(p => p.id === script.id);
    +        }));
    +
    +        // First sort by the order in the preset, then the original order
    +        targetList.sort((a, b) => {
    +            const aIndex = presetList.findIndex(p => p.id === a.id);
    +            const bIndex = presetList.findIndex(p => p.id === b.id);
    +            return aIndex - bIndex || targetList.indexOf(a) - targetList.indexOf(b);
    +        });
    +
    +        await saveFunction(targetList);
    +    }
    +
    +    /**
    +     * Applies a regex preset to the current context.
    +     * @param {string} presetId - The ID of the preset to apply
    +     * @returns {Promise<void>}
    +     */
    +    async applyPreset(presetId) {
    +        const preset = extension_settings.regex_presets.find(p => p.id === presetId);
    +        if (!preset) {
    +            toastr.error(t`Could not find the selected preset.`);
    +            return;
    +        }
    +
    +        // Apply to both global and scoped lists
    +        await this.applyPresetList({
    +            presetList: preset.global,
    +            targetList: extension_settings.regex,
    +            saveFunction: () => saveSettingsDebounced(),
    +        });
    +        await this.applyPresetList({
    +            presetList: preset.scoped,
    +            targetList: characters[this_chid]?.data?.extensions?.regex_scripts,
    +            saveFunction: (scripts) => writeExtensionField(this_chid, 'regex_scripts', scripts),
    +        });
    +
    +        // Render the changes to the UI
    +        await loadRegexScripts();
    +        // Apply the changes to the current chat
    +        await reloadCurrentChat();
    +    }
    +
    +    /**
    +     * Converts a list of regex scripts to preset items.
    +     * @param {RegexScript[]} list The list of regex scripts
    +     * @returns {RegexPresetItem[] | null} The list of preset items, or null if the input is invalid
    +     */
    +    regexListToPresetItems(list) {
    +        if (!Array.isArray(list)) {
    +            return null;
    +        }
    +
    +        return list.filter(x => !x.disabled).map(s => ({ id: s.id }));
    +    }
    +
    +    /**
    +     * Saves a regex preset.
    +     * @param {string} presetId - The ID of the preset
    +     * @param {boolean} isUpdate - Whether this is an update operation
    +     * @returns {Promise<void>}
    +     */
    +    async savePreset(presetId, isUpdate) {
    +        const existingPreset = isUpdate ? extension_settings.regex_presets.find(p => p.id === presetId) : null;
    +
    +        if (isUpdate && !existingPreset) {
    +            toastr.error(t`Could not find the preset to update.`);
    +            return;
    +        }
    +
    +        const name = isUpdate ? existingPreset.name : await Popup.show.input(t`Enter a name for the new regex preset:`, '');
    +        const id = isUpdate ? existingPreset.id : presetId;
    +
    +        if (!name || !name.trim().length) {
    +            return;
    +        }
    +
    +        const preset = {
    +            id: id,
    +            name: name,
    +            isSelected: false,
    +            global: this.regexListToPresetItems(extension_settings.regex),
    +            scoped: this.regexListToPresetItems(characters[this_chid]?.data?.extensions?.regex_scripts),
    +        };
    +
    +        if (isUpdate) {
    +            Object.assign(existingPreset, preset);
    +        } else {
    +            extension_settings.regex_presets.push(preset);
    +        }
    +
    +        extension_settings.regex_presets.forEach(p => { p.isSelected = p.id === id; });
    +        saveSettingsDebounced();
    +
    +        toastr.success(isUpdate ? t`Regex preset updated` : t`Regex preset saved`);
    +    }
    +
    +    /**
    +     * Deletes a regex preset.
    +     * @param {string} presetId - The ID of the preset to delete
    +     * @returns {Promise<void>}
    +     */
    +    async deletePreset(presetId) {
    +        const presetIndex = extension_settings.regex_presets.findIndex(p => p.id === presetId);
    +        if (presetIndex === -1) {
    +            toastr.error(t`Could not find the preset to delete.`);
    +            return;
    +        }
    +
    +        const presetName = extension_settings.regex_presets[presetIndex].name;
    +        const confirm = await Popup.show.confirm(t`Are you sure you want to delete this regex preset?`, presetName);
    +        if (!confirm) {
    +            return;
    +        }
    +
    +        extension_settings.regex_presets.splice(presetIndex, 1);
    +
    +        // Select the first preset if any exist
    +        extension_settings.regex_presets.forEach((p, i) => { p.isSelected = i === 0; });
    +        saveSettingsDebounced();
    +
    +        toastr.success(t`Regex preset deleted`);
    +    }
    +}
    +
    +const presetManager = new RegexPresetManager();
    +
     /**
      * Retrieves the list of regex scripts by combining the scripts from the extension settings and the character data
      *
    @@ -94,13 +540,18 @@ async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {
         if (currentChatId !== undefined && currentChatId !== null) {
             await reloadCurrentChat();
         }
    +
    +    const debuggerPopup = $('#regex_debugger_popup');
    +    if (debuggerPopup.length) {
    +        populateDebuggerRuleList(debuggerPopup.parent());
    +    }
     }
     
     async function deleteRegexScript({ id, isScoped }) {
         const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
     
         const existingScriptIndex = array.findIndex((script) => script.id === id);
    -    if (!existingScriptIndex || existingScriptIndex !== -1) {
    +    if (existingScriptIndex !== -1) {
             array.splice(existingScriptIndex, 1);
     
             if (isScoped) {
    @@ -329,6 +780,442 @@ async function onRegexEditorOpenClick(existingId, isScoped) {
         }
     }
     
    +/**
    + * Builds an HTML string for a replacement, highlighting literal parts in green
    + * and keeping back-referenced parts plain.
    + * @param {RegExpMatchArray} match The match object from `matchAll`.
    + * @param {string} pattern The replacement pattern string (e.g., "new text $1").
    + * @returns {string} The constructed HTML string.
    + */
    +function buildReplacementHtml(match, pattern) {
    +    const container = document.createDocumentFragment();
    +    let lastIndex = 0;
    +    const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g;
    +
    +    let reMatch;
    +    while ((reMatch = backrefRegex.exec(pattern)) !== null) {
    +        // Part of the pattern before the back-reference is a literal.
    +        const literalPart = pattern.substring(lastIndex, reMatch.index);
    +        if (literalPart) {
    +            const mark = document.createElement('mark');
    +            mark.className = 'green_hl';
    +            mark.innerText = literalPart;
    +            container.appendChild(mark);
    +        }
    +
    +        const backref = reMatch[0];
    +        if (backref === '$$') {
    +            container.appendChild(document.createTextNode('$'));
    +        } else if (backref === '$&') {
    +            const mark = document.createElement('mark');
    +            mark.className = 'yellow_hl';
    +            mark.innerText = match[0];
    +            container.appendChild(mark);
    +        } else if (backref === '$`') {
    +            container.appendChild(document.createTextNode(match.input.substring(0, match.index)));
    +        } else if (backref === '$\'') {
    +            container.appendChild(document.createTextNode(match.input.substring(match.index + match[0].length)));
    +        } else { // It's a numbered capture group, $n.
    +            const groupIndex = parseInt(reMatch[1], 10);
    +            if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) {
    +                const mark = document.createElement('mark');
    +                mark.className = 'yellow_hl';
    +                mark.innerText = match[groupIndex];
    +                container.appendChild(mark);
    +            } else {
    +                // Not a valid group index, treat it as a literal.
    +                const mark = document.createElement('mark');
    +                mark.className = 'green_hl';
    +                mark.innerText = backref;
    +                container.appendChild(mark);
    +            }
    +        }
    +        lastIndex = backrefRegex.lastIndex;
    +    }
    +
    +    // The final part of the pattern after the last back-reference.
    +    const finalLiteralPart = pattern.substring(lastIndex);
    +    if (finalLiteralPart) {
    +        const mark = document.createElement('mark');
    +        mark.className = 'green_hl';
    +        mark.innerText = finalLiteralPart;
    +        container.appendChild(mark);
    +    }
    +
    +    // To get the HTML content, we need a temporary parent element.
    +    const tempDiv = document.createElement('div');
    +    tempDiv.appendChild(container);
    +    return tempDiv.innerHTML;
    +}
    +
    +function executeRegexScriptForDebugging(script, text) {
    +    let err;
    +    let originalRegex;
    +
    +    try {
    +        originalRegex = regexFromString(script.findRegex);
    +        if (!originalRegex) throw new Error('Invalid regex string');
    +    } catch (e) {
    +        err = `Compile error: ${e.message}`;
    +        return { output: text, highlightedOutput: text, error: err, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 };
    +    }
    +
    +    const globalRegex = new RegExp(originalRegex.source, originalRegex.flags.includes('g') ? originalRegex.flags : originalRegex.flags + 'g');
    +    const matches = [...text.matchAll(globalRegex)];
    +
    +    if (matches.length === 0) {
    +        return { output: text, highlightedOutput: escapeHtml(text), error: null, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 };
    +    }
    +
    +    let outputText = '';
    +    let highlightedOutput = ''; // This will now be our "diff view"
    +    let lastIndex = 0;
    +    let totalCharsCaptured = 0;
    +    let totalCharsAdded = 0;
    +    let totalCharsRemoved = 0;
    +
    +    try {
    +        for (const match of matches) {
    +            const originalMatchText = match[0];
    +            totalCharsCaptured += originalMatchText.length;
    +
    +            // Append text between matches (this part is unchanged)
    +            const precedingText = text.substring(lastIndex, match.index);
    +            outputText += precedingText;
    +            highlightedOutput += escapeHtml(precedingText);
    +
    +            // --- Start of new diff and statistics logic ---
    +            let charsAddedInMatch = 0;
    +            let charsKeptFromMatch = 0;
    +            const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g;
    +            let lastPatternIndex = 0;
    +            let reMatch;
    +            let replacementForPlainText = '';
    +
    +            // This loop calculates the stats accurately
    +            while ((reMatch = backrefRegex.exec(script.replaceString)) !== null) {
    +                const literalPart = script.replaceString.substring(lastPatternIndex, reMatch.index);
    +                charsAddedInMatch += literalPart.length;
    +                replacementForPlainText += literalPart;
    +                const backref = reMatch[0];
    +                if (backref === '$$') {
    +                    replacementForPlainText += '$';
    +                } else if (backref === '$&') {
    +                    charsKeptFromMatch += (match[0] || '').length; replacementForPlainText += (match[0] || '');
    +                } else if (backref === '$`') {
    +                    const part = match.input.substring(0, match.index); charsKeptFromMatch += part.length; replacementForPlainText += part;
    +                } else if (backref === '$\'') {
    +                    const part = match.input.substring(match.index + match[0].length); charsKeptFromMatch += part.length; replacementForPlainText += part;
    +                } else {
    +                    const groupIndex = parseInt(reMatch[1], 10);
    +                    if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) {
    +                        charsKeptFromMatch += match[groupIndex].length;
    +                        replacementForPlainText += match[groupIndex];
    +                    }
    +                }
    +                lastPatternIndex = backrefRegex.lastIndex;
    +            }
    +            const finalLiteralPart = script.replaceString.substring(lastPatternIndex);
    +            charsAddedInMatch += finalLiteralPart.length;
    +            replacementForPlainText += finalLiteralPart;
    +
    +            totalCharsAdded += charsAddedInMatch;
    +            totalCharsRemoved += (originalMatchText.length - charsKeptFromMatch);
    +
    +            outputText += replacementForPlainText;
    +            // --- End of statistics logic ---
    +
    +            // --- Build the new Diff View HTML ---
    +            // 1. Show the entire original match as "removed" (red strikethrough)
    +            highlightedOutput += `<mark class='red_hl'>${escapeHtml(originalMatchText)}</mark>`;
    +            // 2. Add an arrow to signify transformation
    +            highlightedOutput += ' → ';
    +            // 3. Build the replacement string with green (added) and yellow (kept) parts
    +            highlightedOutput += buildReplacementHtml(match, script.replaceString);
    +
    +            lastIndex = match.index + originalMatchText.length;
    +        }
    +
    +        // Append text after the last match
    +        const trailingText = text.substring(lastIndex);
    +        outputText += trailingText;
    +        highlightedOutput += escapeHtml(trailingText);
    +
    +    } catch (e) {
    +        err = (err ? err + '; ' : '') + `Replace error: ${e.message}`;
    +        outputText = text; // Fallback
    +        highlightedOutput = escapeHtml(text);
    +    }
    +
    +    return {
    +        output: outputText,
    +        highlightedOutput: highlightedOutput,
    +        error: err,
    +        charsCaptured: totalCharsCaptured,
    +        charsAdded: totalCharsAdded,
    +        charsRemoved: totalCharsRemoved,
    +    };
    +}
    +
    +function populateDebuggerRuleList(container) {
    +    const rulesContainer = container.find('#regex_debugger_rules');
    +    const ruleTemplate = container.find('#regex_debugger_rule_template');
    +    if (!rulesContainer.length || !ruleTemplate.length) {
    +        console.error('Regex Debugger: Could not find rule list or template in the DOM.');
    +        return;
    +    }
    +
    +    rulesContainer.empty();
    +
    +    const allScripts = getRegexScripts();
    +    if (!allScripts || allScripts.length === 0) {
    +        rulesContainer.append('<div class="regex-debugger-no-rules">No regex rules found.</div>');
    +        return;
    +    }
    +
    +    const globalScriptIds = new Set((extension_settings.regex ?? []).map(s => s.id));
    +    const globalScripts = [];
    +    const scopedScripts = [];
    +
    +    allScripts.forEach(script => {
    +        const scriptCopy = structuredClone(script); // Use structuredClone for deep copy
    +        if (globalScriptIds.has(script.id)) {
    +            // @ts-ignore
    +            scriptCopy.isScoped = false;
    +            globalScripts.push(scriptCopy);
    +        } else {
    +            // @ts-ignore
    +            scriptCopy.isScoped = true;
    +            scopedScripts.push(scriptCopy);
    +        }
    +    });
    +
    +    container.data('allScripts', [...globalScripts, ...scopedScripts]);
    +
    +    const renderRule = (script) => {
    +        if (!script.id) script.id = uuidv4();
    +        const ruleElementContent = $(ruleTemplate.prop('content')).clone();
    +        const ruleElement = ruleElementContent.find('.regex-debugger-rule');
    +
    +        ruleElement.attr('data-id', script.id);
    +        // @ts-ignore
    +        ruleElement.find('.rule-name').text(script.scriptName);
    +        ruleElement.find('.rule-regex').text(script.findRegex);
    +        // @ts-ignore
    +        ruleElement.find('.rule-scope').text(script.isScoped ? 'Scoped' : 'Global');
    +        ruleElement.find('.rule-enabled').prop('checked', !script.disabled);
    +        // @ts-ignore
    +        ruleElement.find('.edit_rule').on('click', () => onRegexEditorOpenClick(script.id, script.isScoped));
    +
    +        ruleElement.on('click', function (event) {
    +            if ($(event.target).is('input, .menu_button, .menu_button i')) {
    +                return;
    +            }
    +            const scriptId = $(this).data('id');
    +            const stepElement = $(`#step-result-${scriptId}`);
    +            const container = $('#regex_debugger_steps_output');
    +
    +            if (stepElement.length && container.length) {
    +                // Replace scrollIntoView with scrollTop animation
    +                const targetTop = stepElement.position().top;
    +                const containerScrollTop = container.scrollTop();
    +                const containerHeight = container.height();
    +
    +                // Center the element if possible
    +                let scrollTo = containerScrollTop + targetTop - (containerHeight / 2) + (stepElement.height() / 2);
    +
    +                container.animate({ scrollTop: scrollTo }, 300); // 300ms smooth scroll
    +
    +                stepElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)');
    +                setTimeout(() => stepElement.css('background-color', ''), 1000);
    +            }
    +        });
    +
    +        return ruleElementContent;
    +    };
    +
    +    if (globalScripts.length > 0) {
    +        rulesContainer.append('<div class="list-header regex-debugger-list-header">Global Rules</div>');
    +        const globalList = $('<ul id="regex_debugger_rules_global" class="sortable-list"></ul>');
    +        globalScripts.forEach(script => globalList.append(renderRule(script)));
    +        rulesContainer.append(globalList);
    +    }
    +
    +    if (scopedScripts.length > 0) {
    +        rulesContainer.append('<div class="list-header regex-debugger-list-header">Scoped Rules</div>');
    +        const scopedList = $('<ul id="regex_debugger_rules_scoped" class="sortable-list"></ul>');
    +        scopedScripts.forEach(script => scopedList.append(renderRule(script)));
    +        rulesContainer.append(scopedList);
    +    }
    +}
    +
    +/**
    + * Opens the regex debugger.
    + * @returns {Promise<void>}
    + */
    +async function onRegexDebuggerOpenClick() {
    +    const templateContent = await renderExtensionTemplateAsync('regex', 'debugger');
    +    const debuggerHtml = $('<div>').html(templateContent);
    +
    +    const stepTemplate = debuggerHtml.find('#regex_debugger_step_template');
    +
    +    populateDebuggerRuleList(debuggerHtml);
    +
    +    // @ts-ignore
    +    debuggerHtml.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
    +    // @ts-ignore
    +    debuggerHtml.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
    +
    +    debuggerHtml.find('#regex_debugger_run_test').on('click', function () {
    +        const allScripts = debuggerHtml.data('allScripts');
    +        const orderedRuleIds = [
    +            ...$('#regex_debugger_rules_global').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
    +            ...$('#regex_debugger_rules_scoped').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
    +        ];
    +
    +        const rawInput = String($('#regex_debugger_raw_input').val());
    +        const stepsOutput = $('#regex_debugger_steps_output');
    +        const finalOutput = $('#regex_debugger_final_output');
    +
    +        if (!stepsOutput.length || !finalOutput.length) return;
    +
    +        const displayMode = $('input[name="display_mode"]:checked').val();
    +        stepsOutput.empty();
    +        finalOutput.empty();
    +        $('#regex_debugger_final_summary').remove();
    +
    +        if (!allScripts) return;
    +        let textForNextStep = rawInput;
    +        let totalCharsCaptured = 0;
    +        let totalCharsAdded = 0;
    +        let totalCharsRemoved = 0;
    +
    +        orderedRuleIds.forEach(scriptId => {
    +            const ruleElement = $(`#regex_debugger_rules [data-id="${scriptId}"]`);
    +            if (!ruleElement.find('.rule-enabled').is(':checked')) return;
    +
    +            const script = allScripts.find(s => s.id === scriptId);
    +
    +            if (script) {
    +                const result = executeRegexScriptForDebugging(script, textForNextStep);
    +                totalCharsCaptured += result.charsCaptured;
    +                totalCharsAdded += result.charsAdded;
    +                totalCharsRemoved += result.charsRemoved;
    +
    +                const stepElement = $(stepTemplate.prop('content')).clone();
    +                // Set the ID on the TOP-LEVEL element that is being appended.
    +                stepElement.find('>:first-child').attr('id', `step-result-${script.id}`);
    +                const stepHeader = stepElement.find('.step-header');
    +                stepHeader.find('strong').text(`After: ${script.scriptName}`);
    +
    +                const metricsHtml = `<span class="step-metrics">Captured: ${result.charsCaptured}, Added: +${result.charsAdded}, Removed: -${result.charsRemoved}</span>`;
    +                stepHeader.append(metricsHtml);
    +
    +                if (displayMode === 'highlight') {
    +                    stepElement.find('.step-output').html(result.highlightedOutput);
    +                } else {
    +                    stepElement.find('.step-output').text(result.output);
    +                }
    +
    +                if (result.error) {
    +                    stepHeader.append($(`<div class='warning_text text_rose-500'>${result.error}</div>`));
    +                }
    +
    +                stepsOutput.append(stepElement);
    +                textForNextStep = result.output;
    +            }
    +        });
    +
    +        const summaryHtml = `
    +            <div id="regex_debugger_final_summary" class="regex-debugger-summary">
    +                <strong>Total Captured:</strong> ${totalCharsCaptured} | <strong>Total Added:</strong> +${totalCharsAdded} | <strong>Total Removed:</strong> -${totalCharsRemoved}
    +            </div>
    +        `;
    +        finalOutput.before(summaryHtml);
    +
    +        const renderMode = $('#regex_debugger_render_mode').val();
    +        if (renderMode === 'message') {
    +            const formattedHtml = messageFormatting(textForNextStep, 'Debugger', true, false, null);
    +            const messageBlock = $('<div class="mes"><div class="mes_text"></div></div>');
    +            messageBlock.find('.mes_text').html(formattedHtml);
    +            finalOutput.append(messageBlock);
    +        } else {
    +            finalOutput.text(textForNextStep);
    +        }
    +    });
    +
    +    debuggerHtml.find('#regex_debugger_save_order').on('click', async function () {
    +        const allKnownScripts = getRegexScripts();
    +        const newGlobalScripts = $('#regex_debugger_rules_global').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
    +        const newScopedScripts = $('#regex_debugger_rules_scoped').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
    +
    +        extension_settings.regex = newGlobalScripts;
    +        if (this_chid !== undefined) {
    +            await writeExtensionField(this_chid, 'regex_scripts', newScopedScripts);
    +        }
    +
    +        saveSettingsDebounced();
    +        await loadRegexScripts();
    +        toastr.success(t`Regex script order saved!`);
    +
    +        const currentPopupContent = $('div:has(> #regex_debugger_rules)');
    +        populateDebuggerRuleList(currentPopupContent);
    +        // @ts-ignore
    +        currentPopupContent.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
    +        // @ts-ignore
    +        currentPopupContent.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
    +    });
    +
    +    debuggerHtml.find('#regex_debugger_expand_steps').on('click', function () {
    +        const popupContainer = $('<div class="expanded-regex-container"></div>');
    +        const navPanel = $('<div class="expanded-regex-nav"><h4>Steps</h4></div>');
    +        const contentPanel = $('<div class="expanded-regex-content"></div>');
    +
    +        const content = $('#regex_debugger_steps_output').clone().html();
    +        contentPanel.html(content);
    +
    +        $('#regex_debugger_rules .regex-debugger-rule').each(function () {
    +            const ruleElement = $(this);
    +            const scriptId = ruleElement.data('id');
    +            const scriptName = ruleElement.find('.rule-name').text();
    +
    +            const link = $(`<a href="#">${escapeHtml(scriptName)}</a>`);
    +            link.data('target-id', `step-result-${scriptId}`);
    +
    +            link.on('click', function (e) {
    +                e.preventDefault();
    +                navPanel.find('a').removeClass('active');
    +                $(this).addClass('active');
    +
    +                const targetId = $(this).data('target-id');
    +                // The selector is now correct for the structure.
    +                const targetElement = contentPanel.find(`#${targetId}`);
    +
    +                if (targetElement.length) {
    +                    const scrollTo = contentPanel.scrollTop() + targetElement.position().top;
    +                    contentPanel.animate({ scrollTop: scrollTo }, 300);
    +
    +                    targetElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)');
    +                    setTimeout(() => targetElement.css('background-color', ''), 1000);
    +                }
    +            });
    +
    +            navPanel.append(link);
    +        });
    +
    +        popupContainer.append(navPanel).append(contentPanel);
    +        callGenericPopup(popupContainer, POPUP_TYPE.TEXT, 'Step-by-step Transformation', { wide: true, allowVerticalScrolling: false });
    +    });
    +
    +    debuggerHtml.find('#regex_debugger_expand_final').on('click', function () {
    +        const content = $('#regex_debugger_final_output').html();
    +        const popupContent = $('<div class="regex-popup-content"></div>').html(content);
    +        callGenericPopup(popupContent, POPUP_TYPE.TEXT, 'Final Output', { wide: true, large: true, allowVerticalScrolling: true });
    +    });
    +
    +    await callGenericPopup(debuggerHtml.children(), POPUP_TYPE.TEXT, '', { wide: true, allowVerticalScrolling: true });
    +}
    +
     /**
      * Updates the info block in the regex editor with hints regarding the find regex.
      * @param {JQuery<HTMLElement>} editorHtml The editor HTML
    @@ -592,20 +1479,27 @@ async function checkEmbeddedRegexScripts() {
     // Workaround for loading in sequence with other extensions
     // NOTE: Always puts extension at the top of the list, but this is fine since it's static
     jQuery(async () => {
    -    if (extension_settings.regex) {
    -        migrateSettings();
    +    if (!Array.isArray(extension_settings.regex)) {
    +        extension_settings.regex = [];
    +    }
    +
    +    if (!Array.isArray(extension_settings.regex_presets)) {
    +        extension_settings.regex_presets = [];
         }
     
         // Manually disable the extension since static imports auto-import the JS file
         if (extension_settings.disabledExtensions.includes('regex')) {
             return;
         }
     
    +    migrateSettings();
    +
         const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown'));
         $('#regex_container').append(settingsHtml);
         $('#open_regex_editor').on('click', function () {
             onRegexEditorOpenClick(false, false);
         });
    +    $('#open_regex_debugger').on('click', onRegexDebuggerOpenClick);
         $('#open_scoped_editor').on('click', function () {
             if (this_chid === undefined) {
                 toastr.error(t`No character selected.`);
    @@ -726,6 +1620,7 @@ jQuery(async () => {
             },
         ];
         for (const { selector, setter, getter } of sortableDatas) {
    +        // @ts-ignore
             $(selector).sortable({
                 delay: getSortableDelay(),
                 stop: async function () {
    @@ -778,6 +1673,7 @@ jQuery(async () => {
         });
     
         await loadRegexScripts();
    +    // @ts-ignore
         $('#saved_regex_scripts').sortable('enable');
     
         const localEnumProviders = {
    @@ -856,4 +1752,7 @@ jQuery(async () => {
     
         eventSource.on(event_types.CHAT_CHANGED, checkEmbeddedRegexScripts);
         eventSource.on(event_types.CHARACTER_DELETED, purgeEmbeddedRegexScripts);
    +
    +    presetManager.setupEventListeners();
    +    presetManager.registerSlashCommands();
     });
    
  • public/scripts/extensions/regex/style.css+10 8 modified
    @@ -1,3 +1,5 @@
    +@import "debugger.css";
    +
     .regex_settings .menu_button {
         width: fit-content;
         display: flex;
    @@ -39,19 +41,19 @@
         opacity: 0.5;
     }
     
    -.enable_scoped:checked ~ .regex-toggle-on {
    +.enable_scoped:checked~.regex-toggle-on {
         display: block;
     }
     
    -.enable_scoped:checked ~ .regex-toggle-off {
    +.enable_scoped:checked~.regex-toggle-off {
         display: none;
     }
     
    -.enable_scoped:not(:checked) ~ .regex-toggle-on {
    +.enable_scoped:not(:checked)~.regex-toggle-on {
         display: none;
     }
     
    -.enable_scoped:not(:checked) ~ .regex-toggle-off {
    +.enable_scoped:not(:checked)~.regex-toggle-off {
         display: block;
     }
     
    @@ -90,19 +92,19 @@ input.enable_scoped {
         cursor: pointer;
     }
     
    -.disable_regex:checked ~ .regex-toggle-off {
    +.disable_regex:checked~.regex-toggle-off {
         display: block;
     }
     
    -.disable_regex:checked ~ .regex-toggle-on {
    +.disable_regex:checked~.regex-toggle-on {
         display: none;
     }
     
    -.disable_regex:not(:checked) ~ .regex-toggle-off {
    +.disable_regex:not(:checked)~.regex-toggle-off {
         display: none;
     }
     
    -.disable_regex:not(:checked) ~ .regex-toggle-on {
    +.disable_regex:not(:checked)~.regex-toggle-on {
         display: block;
     }
     
    
  • public/scripts/extensions/shared.js+19 6 modified
    @@ -22,12 +22,6 @@ export async function getMultimodalCaption(base64Img, prompt) {
     
         throwIfInvalidModel(useReverseProxy);
     
    -    const noPrefix = ['ollama'].includes(extension_settings.caption.multimodal_api);
    -
    -    if (noPrefix && base64Img.startsWith('data:image/')) {
    -        base64Img = base64Img.split(',')[1];
    -    }
    -
         // 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 isOllama = extension_settings.caption.multimodal_api === 'ollama';
    @@ -47,6 +41,9 @@ export async function getMultimodalCaption(base64Img, prompt) {
         } else if (!safeMimeTypes.includes(mimeType)) {
             base64Img = await createThumbnail(base64Img, null, null);
         }
    +    if (isOllama && base64Img.startsWith('data:image/')) {
    +        base64Img = base64Img.split(',')[1];
    +    }
     
         const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : '';
         const proxyPassword = useReverseProxy ? oai_settings.proxy_password : '';
    @@ -72,6 +69,10 @@ export async function getMultimodalCaption(base64Img, prompt) {
                 requestBody.model = textgenerationwebui_settings.ollama_model;
             }
     
    +        if (extension_settings.caption.multimodal_model === 'ollama_custom') {
    +            requestBody.model = extension_settings.caption.ollama_custom_model;
    +        }
    +
             requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
                 ? extension_settings.caption.alt_endpoint_url
                 : textgenerationwebui_settings.server_urls[textgen_types.OLLAMA];
    @@ -211,6 +212,10 @@ function throwIfInvalidModel(useReverseProxy) {
             throw new Error('Ollama model is not set.');
         }
     
    +    if (multimodalApi === 'ollama' && multimodalModel === 'ollama_custom' && !extension_settings.caption.ollama_custom_model) {
    +        throw new Error('Ollama custom model tag is not set.');
    +    }
    +
         if (multimodalApi === 'llamacpp' && !textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP] && !altEndpointEnabled) {
             throw new Error('LlamaCPP server URL is not set.');
         }
    @@ -242,6 +247,14 @@ function throwIfInvalidModel(useReverseProxy) {
         if (multimodalApi === 'moonshot' && !secret_state[SECRET_KEYS.MOONSHOT]) {
             throw new Error('Moonshot AI API key is not set.');
         }
    +
    +    if (multimodalApi === 'nanogpt' && !secret_state[SECRET_KEYS.NANOGPT]) {
    +        throw new Error('NanoGPT API key is not set.');
    +    }
    +
    +    if (multimodalApi === 'electronhub' && !secret_state[SECRET_KEYS.ELECTRONHUB]) {
    +        throw new Error('Electron Hub API key is not set.');
    +    }
     }
     
     /**
    
  • public/scripts/extensions/stable-diffusion/index.js+129 13 modified
    @@ -57,7 +57,7 @@ import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
     import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
     import { ToolManager } from '../../tool-calling.js';
     import { MacrosParser } from '../../macros.js';
    -import { t } from '../../i18n.js';
    +import { t, translate } from '../../i18n.js';
     import { oai_settings } from '../../openai.js';
     
     export { MODULE_NAME };
    @@ -82,6 +82,7 @@ const sources = {
         pollinations: 'pollinations',
         stability: 'stability',
         huggingface: 'huggingface',
    +    electronhub: 'electronhub',
         nanogpt: 'nanogpt',
         bfl: 'bfl',
         falai: 'falai',
    @@ -650,7 +651,7 @@ async function onDeleteStyleClick() {
             return;
         }
     
    -    const confirmed = await callGenericPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
    +    const confirmed = await callGenericPopup(t`Are you sure you want to delete the style "${selectedStyle}"?`, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
     
         if (!confirmed) {
             return;
    @@ -925,16 +926,16 @@ function onADetailerFaceChange() {
     }
     
     const resolutionOptions = {
    -    sd_res_512x512: { width: 512, height: 512, name: '512x512 (1:1, icons, profile pictures)' },
    -    sd_res_600x600: { width: 600, height: 600, name: '600x600 (1:1, icons, profile pictures)' },
    -    sd_res_512x768: { width: 512, height: 768, name: '512x768 (2:3, vertical character card)' },
    -    sd_res_768x512: { width: 768, height: 512, name: '768x512 (3:2, horizontal 35-mm movie film)' },
    -    sd_res_960x540: { width: 960, height: 540, name: '960x540 (16:9, horizontal wallpaper)' },
    -    sd_res_540x960: { width: 540, height: 960, name: '540x960 (9:16, vertical wallpaper)' },
    -    sd_res_1920x1088: { width: 1920, height: 1088, name: '1920x1088 (16:9, 1080p, horizontal wallpaper)' },
    -    sd_res_1088x1920: { width: 1088, height: 1920, name: '1088x1920 (9:16, 1080p, vertical wallpaper)' },
    -    sd_res_1280x720: { width: 1280, height: 720, name: '1280x720 (16:9, 720p, horizontal wallpaper)' },
    -    sd_res_720x1280: { width: 720, height: 1280, name: '720x1280 (9:16, 720p, vertical wallpaper)' },
    +    sd_res_512x512: { width: 512, height: 512, name: translate('512x512 (1:1, icons, profile pictures)', 'sd_res_512x512') },
    +    sd_res_600x600: { width: 600, height: 600, name: translate('600x600 (1:1, icons, profile pictures)', 'sd_res_600x600') },
    +    sd_res_512x768: { width: 512, height: 768, name: translate('512x768 (2:3, vertical character card)', 'sd_res_512x768') },
    +    sd_res_768x512: { width: 768, height: 512, name: translate('768x512 (3:2, horizontal 35-mm movie film)', 'sd_res_768x512') },
    +    sd_res_960x540: { width: 960, height: 540, name: translate('960x540 (16:9, horizontal wallpaper)', 'sd_res_960x540') },
    +    sd_res_540x960: { width: 540, height: 960, name: translate('540x960 (9:16, vertical wallpaper)', 'sd_res_540x960') },
    +    sd_res_1920x1088: { width: 1920, height: 1088, name: translate('1920x1088 (16:9, 1080p, horizontal wallpaper)', 'sd_res_1920x1088') },
    +    sd_res_1088x1920: { width: 1088, height: 1920, name: translate('1088x1920 (9:16, 1080p, vertical wallpaper)', 'sd_res_1088x1920') },
    +    sd_res_1280x720: { width: 1280, height: 720, name: translate('1280x720 (16:9, 720p, horizontal wallpaper)', 'sd_res_1280x720') },
    +    sd_res_720x1280: { width: 720, height: 1280, name: translate('720x1280 (9:16, 720p, vertical wallpaper)', 'sd_res_720x1280') },
         sd_res_1024x1024: { width: 1024, height: 1024, name: '1024x1024 (1:1, SDXL)' },
         sd_res_1152x896: { width: 1152, height: 896, name: '1152x896 (9:7, SDXL)' },
         sd_res_896x1152: { width: 896, height: 1152, name: '896x1152 (7:9, SDXL)' },
    @@ -1289,6 +1290,7 @@ async function onModelChange() {
             sources.pollinations,
             sources.stability,
             sources.huggingface,
    +        sources.electronhub,
             sources.nanogpt,
             sources.bfl,
             sources.falai,
    @@ -1506,6 +1508,9 @@ async function loadSamplers() {
             case sources.huggingface:
                 samplers = ['N/A'];
                 break;
    +        case sources.electronhub:
    +            samplers = ['N/A'];
    +            break;
             case sources.nanogpt:
                 samplers = ['N/A'];
                 break;
    @@ -1702,6 +1707,9 @@ async function loadModels() {
             case sources.huggingface:
                 models = [{ value: '', text: '<Enter Model ID above>' }];
                 break;
    +        case sources.electronhub:
    +            models = await loadElectronHubModels();
    +            break;
             case sources.nanogpt:
                 models = await loadNanoGPTModels();
                 break;
    @@ -1806,6 +1814,24 @@ async function loadTogetherAIModels() {
         return [];
     }
     
    +async function loadElectronHubModels() {
    +    if (!secret_state[SECRET_KEYS.ELECTRONHUB]) {
    +        console.debug('Electron Hub API key is not set.');
    +        return [];
    +    }
    +
    +    const result = await fetch('/api/sd/electronhub/models', {
    +        method: 'POST',
    +        headers: getRequestHeaders(),
    +    });
    +
    +    if (result.ok) {
    +        return await result.json();
    +    }
    +
    +    return [];
    +}
    +
     async function loadNanoGPTModels() {
         if (!secret_state[SECRET_KEYS.NANOGPT]) {
             console.debug('NanoGPT API key is not set.');
    @@ -2131,6 +2157,9 @@ async function loadSchedulers() {
             case sources.huggingface:
                 schedulers = ['N/A'];
                 break;
    +        case sources.electronhub:
    +            schedulers = ['N/A'];
    +            break;
             case sources.nanogpt:
                 schedulers = ['N/A'];
                 break;
    @@ -2228,6 +2257,9 @@ async function loadVaes() {
             case sources.huggingface:
                 vaes = ['N/A'];
                 break;
    +        case sources.electronhub:
    +            vaes = ['N/A'];
    +            break;
             case sources.nanogpt:
                 vaes = ['N/A'];
                 break;
    @@ -2811,6 +2843,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
                 case sources.huggingface:
                     result = await generateHuggingFaceImage(prefixedPrompt, signal);
                     break;
    +            case sources.electronhub:
    +                result = await generateElectronHubImage(prefixedPrompt, signal);
    +                break;
                 case sources.nanogpt:
                     result = await generateNanoGPTImage(prefixedPrompt, negativePrompt, signal);
                     break;
    @@ -3013,6 +3048,56 @@ function getClosestAspectRatio(width, height, source) {
         return closestAspectRatio;
     }
     
    +/**
    + * Get closest size for Electron Hub
    + * @param {number} width - The width of the image
    + * @param {number} height - The height of the image
    + * @returns {Promise<string>} - The closest size
    + */
    +async function getClosestSize(width, height) {
    +    const response = await fetch('/api/sd/electronhub/sizes', {
    +        method: 'POST',
    +        headers: getRequestHeaders(),
    +        body: JSON.stringify({
    +            model: extension_settings.sd.model,
    +        }),
    +    });
    +    if (!response.ok) {
    +        const text = await response.text();
    +        throw new Error(text);
    +    }
    +    const result = await response.json();
    +    const sizesData = result.sizes;
    +
    +    const closestSize = sizesData.reduce((closest, size) => {
    +        if (!size || typeof size !== 'string') {
    +            return closest;
    +        }
    +        const sizeParts = size.split('x');
    +        if (sizeParts.length !== 2) {
    +            return closest;
    +        }
    +
    +        const sizeWidth = Number(sizeParts[0]);
    +        const sizeHeight = Number(sizeParts[1]);
    +        const targetWidth = Number(width);
    +        const targetHeight = Number(height);
    +
    +        if (isNaN(sizeWidth) || isNaN(sizeHeight) || isNaN(targetWidth) || isNaN(targetHeight)) {
    +            return closest;
    +        }
    +
    +        const sizeArea = sizeWidth * sizeHeight;
    +        const targetArea = targetWidth * targetHeight;
    +        const diff = Math.abs(sizeArea - targetArea);
    +
    +        return diff < closest.diff ? { size, diff } : closest;
    +    }, { size: null, diff: Infinity });
    +
    +    const size = closestSize.size;
    +    return size;
    +}
    +
     /**
      * Generates an image using Stability AI.
      * @param {string} prompt - The main instruction used to guide the image generation.
    @@ -3564,6 +3649,35 @@ async function generateHuggingFaceImage(prompt, signal) {
         }
     }
     
    +/**
    + * Generates an image using the Electron Hub API.
    + * @param {string} prompt - The main instruction used to guide the image generation.
    + * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
    + * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
    + */
    +async function generateElectronHubImage(prompt, signal) {
    +    const size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height);
    +
    +    const result = await fetch('/api/sd/electronhub/generate', {
    +        method: 'POST',
    +        headers: getRequestHeaders(),
    +        signal: signal,
    +        body: JSON.stringify({
    +            model: extension_settings.sd.model,
    +            prompt: prompt,
    +            size: size,
    +        }),
    +    });
    +
    +    if (result.ok) {
    +        const data = await result.json();
    +        return { format: 'jpg', data: data.image };
    +    } else {
    +        const text = await result.text();
    +        throw new Error(text);
    +    }
    +}
    +
     /**
      * Generates an image using the NanoGPT API.
      * @param {string} prompt - The main instruction used to guide the image generation.
    @@ -3847,7 +3961,7 @@ async function onComfyNewWorkflowClick() {
     }
     
     async function onComfyDeleteWorkflowClick() {
    -    const confirm = await callGenericPopup('Delete the workflow? This action is irreversible.', POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
    +    const confirm = await callGenericPopup(t`Delete the workflow? This action is irreversible.`, POPUP_TYPE.CONFIRM, '', { okButton: t`Delete`, cancelButton: t`Cancel` });
         if (!confirm) {
             return;
         }
    @@ -4014,6 +4128,8 @@ function isValidState() {
                 return secret_state[SECRET_KEYS.STABILITY];
             case sources.huggingface:
                 return secret_state[SECRET_KEYS.HUGGINGFACE];
    +        case sources.electronhub:
    +            return secret_state[SECRET_KEYS.ELECTRONHUB];
             case sources.nanogpt:
                 return secret_state[SECRET_KEYS.NANOGPT];
             case sources.bfl:
    
  • public/scripts/extensions/stable-diffusion/settings.html+5 1 modified
    @@ -41,6 +41,7 @@
                     <option value="bfl">BFL (Black Forest Labs)</option>
                     <option value="comfy">ComfyUI</option>
                     <option value="drawthings">DrawThings HTTP API</option>
    +                <option value="electronhub">Electron Hub</option>
                     <option value="extras">Extras API (deprecated)</option>
                     <option value="falai">FAL.AI</option>
                     <option value="google">Google AI</option>
    @@ -93,6 +94,9 @@
                     <label for="sd_huggingface_model_id" data-i18n="Model ID">Model ID</label>
                     <input id="sd_huggingface_model_id" type="text" class="text_pole"  data-i18n="[placeholder]e.g. black-forest-labs/FLUX.1-dev" placeholder="e.g. black-forest-labs/FLUX.1-dev" value="" />
                 </div>
    +            <div data-sd-source="electronhub">
    +                <i>Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.</i>
    +            </div>
                 <div data-sd-source="nanogpt">
                     <i>Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.</i>
                 </div>
    @@ -260,7 +264,7 @@
                             <span data-i18n="Click to set">Click to set</span>
                         </div>
                     </div>
    -                <label class="checkbox_label marginBot5" for="sd_bfl_upsampling" title="Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.">
    +                <label class="checkbox_label marginBot5" for="sd_bfl_upsampling" data-i18n="[title]Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation." title="Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.">
                         <input id="sd_bfl_upsampling" type="checkbox" />
                         <span data-i18n="Prompt Upsampling">
                             Prompt Upsampling
    
  • public/scripts/extensions/translate/index.js+4 2 modified
    @@ -182,9 +182,11 @@ function isGeneratingSwipe(messageId) {
         return $(`#chat .mes[mesid="${messageId}"] .mes_text`).text() === '...';
     }
     
    -async function translateImpersonate(text) {
    +async function translateImpersonate() {
    +    const sendTextArea = $('#send_textarea');
    +    const text = sendTextArea.val().toString();
         const translatedText = await translate(text, extension_settings.translate.target_language);
    -    $('#send_textarea').val(translatedText);
    +    sendTextArea.val(translatedText);
     }
     
     /**
    
  • public/scripts/extensions/tts/index.js+77 4 modified
    @@ -637,11 +637,8 @@ async function processTtsQueue() {
         }
     
         if (extension_settings.tts.narrate_quoted_only) {
    -        const special_quotes = /[“”«»「」『』""]/g; // Extend this regex to include other special quotes
    -        text = text.replace(special_quotes, '"');
    -        const matches = text.match(/".*?"/g); // Matches text inside double quotes, non-greedily
             const partJoiner = (ttsProvider?.separator || ' ... ');
    -        text = matches ? matches.join(partJoiner) : text;
    +        text = joinQuotedBlocks(text, { separator: partJoiner, includeQuotes: true });
         }
     
         // Remove embedded images
    @@ -702,6 +699,82 @@ async function processTtsQueue() {
         }
     }
     
    +/**
    + * Extract and join quoted blocks with proper matching pairs and nesting.
    + * - Captures outermost quotes and everything inside (including different inner quote styles).
    + * - Requires matching opener/closer style (e.g., “ ... ”, 「 ... 」, « ... », etc.).
    + * - Ignores incomplete/unclosed quotes (doesn't include them in the result).
    + * - Symmetric quotes like "..." and "..." are supported (not nesting the same symmetric style).
    + *
    + * @param {string} text - The text to process
    + * @param {object} [opts={}] - Optional options object
    + * @param {string} [opts.separator=' ... '] - String to join multiple quoted blocks
    + * @param {boolean} [opts.includeQuotes=true] - Keep the quote chars around the captured text
    + * @param {boolean} [opts.returnEmptyOnNoQuotes=false] - Return an empty string if no quotes are found
    + * @param {Array<[string,string]>} [opts.pairs] - Custom quote pairs; defaults cover EN/DE/FR/JP
    + * @returns {string} The joined quoted blocks, or the original text if no quotes found
    + */
    +function joinQuotedBlocks(text, opts = {}) {
    +    const {
    +        separator = ' ... ',
    +        includeQuotes = true,
    +        returnEmptyOnNoQuotes = false,
    +        pairs = [
    +            // typographic doubles
    +            ['„', '“'],          // DE low-high
    +            ['“', '”'],          // EN
    +            ['«', '»'],          // FR open « close »
    +            ['»', '«'],          // Some locales open »
    +            // typographic singles
    +            ['‘', '’'],
    +            ['‚', '‘'],
    +            // Japanese corner quotes
    +            ['「', '」'],
    +            ['『', '』'],
    +            // symmetric doubles
    +            ['"', '"'],
    +            ['"', '"'],
    +        ],
    +    } = opts;
    +
    +    if (!text || typeof text !== 'string') return text;
    +
    +    const openToClose = Object.fromEntries(pairs);
    +
    +    const segments = [];
    +    const stack = []; // [{ opener, expectedClose, start }]
    +    for (let i = 0; i < text.length; i++) {
    +        const ch = text[i];
    +        const top = stack[stack.length - 1];
    +
    +        // Prefer closing the current open pair if the char matches its expected closer
    +        if (top && ch === top.expectedClose) {
    +            const finished = stack.pop();
    +            if (stack.length === 0) {
    +                // Only collect outermost quotes (contains all nested content)
    +                segments.push(text.slice(finished.start, i + 1));
    +            }
    +            continue;
    +        }
    +
    +        // Otherwise, see if this is a new opener
    +        if (openToClose[ch]) {
    +            stack.push({ opener: ch, expectedClose: openToClose[ch], start: i });
    +            continue;
    +        }
    +
    +        // If it's a stray closer that doesn't match current top, ignore
    +    }
    +
    +    if (!segments.length) return returnEmptyOnNoQuotes ? '' : text;
    +
    +    const cleaned = includeQuotes
    +        ? segments
    +        : segments.map(s => s.slice(1, -1)); // all defined pairs are single-char quotes
    +
    +    return cleaned.join(separator);
    +}
    +
     async function playFullConversation() {
         resetTtsPlayback();
     
    
  • public/scripts/keyboard.js+1 1 modified
    @@ -11,7 +11,7 @@ const interactableSelectors = [
         '.avatar-container', // Persona list blocks
         '.tag .tag_remove', // Remove button in removable tags
         '.bg_example', // Background elements in the background menu
    -    '.bg_example .bg_button', // The inline buttons on the backgrounds
    +    '.bg_example .jg-button, .bg_example .mobile-only-menu-toggle', // The inline buttons on the backgrounds
         '#options a', // Option entries in the popup options menu
         '.mes_buttons .mes_button', // Small inline buttons on the chat messages
         '.extraMesButtons>div:not(.mes_button)', // The extra/extension buttons inline on the chat messages
    
  • public/scripts/login.js+4 0 modified
    @@ -1,3 +1,5 @@
    +import { initAccessibility } from './a11y.js';
    +
     /**
      * CRSF token for requests.
      */
    @@ -265,6 +267,8 @@ function configureDiscreetLogin() {
     }
     
     (async function () {
    +    initAccessibility();
    +
         csrfToken = await getCsrfToken();
         const userList = await getUserList();
     
    
  • public/scripts/openai.js+301 57 modified
    @@ -179,6 +179,7 @@ export const chat_completion_sources = {
         COHERE: 'cohere',
         PERPLEXITY: 'perplexity',
         GROQ: 'groq',
    +    ELECTRONHUB: 'electronhub',
         NANOGPT: 'nanogpt',
         DEEPSEEK: 'deepseek',
         AIMLAPI: 'aimlapi',
    @@ -187,6 +188,7 @@ export const chat_completion_sources = {
         MOONSHOT: 'moonshot',
         FIREWORKS: 'fireworks',
         COMETAPI: 'cometapi',
    +    AZURE_OPENAI: 'azure_openai',
     };
     
     const character_names_behavior = {
    @@ -240,6 +242,8 @@ const sensitiveFields = [
         'custom_include_headers',
         'vertexai_region',
         'vertexai_express_project_id',
    +    'azure_base_url',
    +    'azure_deployment_name',
     ];
     
     /**
    @@ -271,6 +275,7 @@ export const settingsToUpdate = {
         cohere_model: ['#model_cohere_select', 'cohere_model', false, true],
         perplexity_model: ['#model_perplexity_select', 'perplexity_model', false, true],
         groq_model: ['#model_groq_select', 'groq_model', false, true],
    +    electronhub_model: ['#model_electronhub_select', 'electronhub_model', false, true],
         nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],
         deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],
         aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],
    @@ -329,6 +334,10 @@ export const settingsToUpdate = {
         n: ['#n_openai', 'n', false, false],
         bypass_status_check: ['#openai_bypass_status_check', 'bypass_status_check', true, true],
         request_images: ['#openai_request_images', 'request_images', true, false],
    +    azure_base_url: ['#azure_base_url', 'azure_base_url', false, true],
    +    azure_deployment_name: ['#azure_deployment_name', 'azure_deployment_name', false, true],
    +    azure_api_version: ['#azure_api_version', 'azure_api_version', false, true],
    +    azure_openai_model: ['#azure_openai_model', 'azure_openai_model', false, true],
         extensions: ['#NULL_SELECTOR', 'extensions', false, false],
     };
     
    @@ -369,6 +378,7 @@ const default_settings = {
         cohere_model: 'command-r-plus',
         perplexity_model: 'sonar-pro',
         groq_model: 'llama-3.3-70b-versatile',
    +    electronhub_model: 'gpt-4o-mini',
         nanogpt_model: 'gpt-4o-mini',
         deepseek_model: 'deepseek-chat',
         aimlapi_model: 'gpt-4o-mini-2024-07-18',
    @@ -377,6 +387,10 @@ const default_settings = {
         cometapi_model: 'gpt-4o',
         moonshot_model: 'kimi-latest',
         fireworks_model: 'accounts/fireworks/models/kimi-k2-instruct',
    +    azure_base_url: '',
    +    azure_deployment_name: '',
    +    azure_api_version: '2024-02-15-preview',
    +    azure_openai_model: '',
         custom_model: '',
         custom_url: '',
         custom_include_body: '',
    @@ -458,6 +472,7 @@ const oai_settings = {
         cohere_model: 'command-r-plus',
         perplexity_model: 'sonar-pro',
         groq_model: 'llama-3.1-70b-versatile',
    +    electronhub_model: 'gpt-4o-mini',
         nanogpt_model: 'gpt-4o-mini',
         deepseek_model: 'deepseek-chat',
         aimlapi_model: 'gpt-4-turbo',
    @@ -466,6 +481,10 @@ const oai_settings = {
         cometapi_model: 'gpt-4o',
         moonshot_model: 'kimi-latest',
         fireworks_model: 'accounts/fireworks/models/kimi-k2-instruct',
    +    azure_base_url: '',
    +    azure_deployment_name: '',
    +    azure_api_version: '2024-02-15-preview',
    +    azure_openai_model: '',
         custom_model: '',
         custom_url: '',
         custom_include_body: '',
    @@ -905,7 +924,6 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
     
         // Insert chat messages as long as there is budget available
         const chatPool = [...messages].reverse();
    -    const firstNonInjected = chatPool.find(x => !x.injected);
         for (let index = 0; index < chatPool.length; index++) {
             const chatPrompt = chatPool[index];
     
    @@ -946,22 +964,6 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
             }
     
             if (chatCompletion.canAfford(chatMessage)) {
    -            if (type === 'continue' && oai_settings.continue_prefill && chatPrompt === firstNonInjected) {
    -                // in case we are using continue_prefill and the latest message is an assistant message, we want to prepend the users assistant prefill on the message
    -                if (chatPrompt.role === 'assistant') {
    -                    const supportsAssistantPrefill = oai_settings.chat_completion_source === chat_completion_sources.CLAUDE;
    -                    const assistantPrefill = supportsAssistantPrefill ? substituteParams(oai_settings.assistant_prefill) : '';
    -                    const messageContent = [assistantPrefill, chatMessage.content].filter(x => x).join('\n\n');
    -                    const continueMessage = await Message.createAsync(chatMessage.role, messageContent, chatMessage.identifier);
    -                    const collection = new MessageCollection('continuePrefill', continueMessage);
    -                    chatCompletion.add(collection, -1);
    -                    continue;
    -                }
    -                const collection = new MessageCollection('continuePrefill', chatMessage);
    -                chatCompletion.add(collection, -1);
    -                continue;
    -            }
    -
                 chatCompletion.insertAtStart(chatMessage, 'chatHistory');
             } else {
                 break;
    @@ -1221,6 +1223,21 @@ async function populateChatCompletion(prompts, chatCompletion, { bias, quietProm
             chatCompletion.reserveBudget(toolTokens);
         }
     
    +    // Displace the message to be continued from its original position before performing in-chat injections
    +    // In case if it is an assistant message, we want to prepend the users assistant prefill on the message
    +    if (type === 'continue' && oai_settings.continue_prefill && messages.length) {
    +        const chatMessage = messages.shift();
    +        const isAssistantRole = chatMessage.role === 'assistant';
    +        const supportsAssistantPrefill = oai_settings.chat_completion_source === chat_completion_sources.CLAUDE;
    +        const namesInCompletion = oai_settings.names_behavior === character_names_behavior.COMPLETION;
    +        const assistantPrefill = isAssistantRole && supportsAssistantPrefill ? substituteParams(oai_settings.assistant_prefill) : '';
    +        const messageContent = [assistantPrefill, chatMessage.content].filter(x => x).join('\n\n');
    +        const continueMessage = await Message.createAsync(chatMessage.role, messageContent, 'continuePrefill');
    +        chatMessage.name && namesInCompletion && await continueMessage.setName(promptManager.sanitizeName(chatMessage.name));
    +        controlPrompts.add(continueMessage);
    +        chatCompletion.reserveBudget(continueMessage);
    +    }
    +
         // Add in-chat injections
         messages = await populationInjectionPrompts(absolutePrompts, messages);
     
    @@ -1544,6 +1561,11 @@ export function tryParseStreamingError(response, decoded, { quiet = false } = {}
                 !quiet && toastr.error(data.message, 'Chat Completion API');
                 throw new Error(data);
             }
    +
    +        if (data.detail) {
    +            !quiet && toastr.error(data.detail?.error?.message || response.statusText, 'Chat Completion API');
    +            throw new Error(data);
    +        }
         }
         catch {
             // No JSON. Do nothing.
    @@ -1616,6 +1638,8 @@ export function getChatCompletionModel(source = null) {
                 return oai_settings.perplexity_model;
             case chat_completion_sources.GROQ:
                 return oai_settings.groq_model;
    +        case chat_completion_sources.ELECTRONHUB:
    +            return oai_settings.electronhub_model;
             case chat_completion_sources.NANOGPT:
                 return oai_settings.nanogpt_model;
             case chat_completion_sources.DEEPSEEK:
    @@ -1632,6 +1656,8 @@ export function getChatCompletionModel(source = null) {
                 return oai_settings.moonshot_model;
             case chat_completion_sources.FIREWORKS:
                 return oai_settings.fireworks_model;
    +        case chat_completion_sources.AZURE_OPENAI:
    +            return oai_settings.azure_openai_model;
             default:
                 console.error(`Unknown chat completion source: ${activeSource}`);
                 return '';
    @@ -1776,6 +1802,26 @@ function saveModelList(data) {
             }
         }
     
    +    if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB) {
    +        $('#model_electronhub_select').empty();
    +        model_list.forEach((model) => {
    +            if (model?.endpoints?.includes('/v1/chat/completions')) {
    +                $('#model_electronhub_select').append(
    +                    $('<option>', {
    +                        value: model.id,
    +                        text: model.name,
    +                    }));
    +            }
    +        });
    +
    +        const selectedModel = model_list.find(model => model.id === oai_settings.electronhub_model);
    +        if (model_list.length > 0 && (!selectedModel || !oai_settings.electronhub_model)) {
    +            oai_settings.electronhub_model = model_list[0].id;
    +        }
    +
    +        $('#model_electronhub_select').val(oai_settings.electronhub_model).trigger('change');
    +    }
    +
         if (oai_settings.chat_completion_source == chat_completion_sources.NANOGPT) {
             $('#model_nanogpt_select').empty();
             model_list.forEach((model) => {
    @@ -1928,6 +1974,16 @@ function saveModelList(data) {
     
             $('#model_cometapi_select').val(oai_settings.cometapi_model).trigger('change');
         }
    +
    +    if (oai_settings.chat_completion_source == chat_completion_sources.AZURE_OPENAI) {
    +        const modelId = model_list?.[0]?.id || '';
    +        oai_settings.azure_openai_model = modelId;
    +
    +        $('#azure_openai_model')
    +            .empty()
    +            .append(new Option(modelId || 'None', modelId || '', true, true))
    +            .trigger('change');
    +    }
     }
     
     function appendOpenRouterOptions(model_list, groupModels = false, sort = false) {
    @@ -2039,31 +2095,51 @@ function getReasoningEffort() {
         // These sources expect the effort as string.
         const reasoningEffortSources = [
             chat_completion_sources.OPENAI,
    +        chat_completion_sources.AZURE_OPENAI,
             chat_completion_sources.CUSTOM,
             chat_completion_sources.XAI,
             chat_completion_sources.AIMLAPI,
             chat_completion_sources.OPENROUTER,
             chat_completion_sources.POLLINATIONS,
             chat_completion_sources.PERPLEXITY,
             chat_completion_sources.COMETAPI,
    +        chat_completion_sources.ELECTRONHUB,
         ];
     
         if (!reasoningEffortSources.includes(oai_settings.chat_completion_source)) {
             return oai_settings.reasoning_effort;
         }
     
    -    switch (oai_settings.reasoning_effort) {
    -        case reasoning_effort_types.auto:
    +    function resolveReasoningEffort() {
    +        switch (oai_settings.reasoning_effort) {
    +            case reasoning_effort_types.auto:
    +                return undefined;
    +            case reasoning_effort_types.min:
    +                return [chat_completion_sources.OPENAI, chat_completion_sources.AZURE_OPENAI].includes(oai_settings.chat_completion_source) && /^gpt-5/.test(getChatCompletionModel())
    +                    ? reasoning_effort_types.min
    +                    : reasoning_effort_types.low;
    +            case reasoning_effort_types.max:
    +                return reasoning_effort_types.high;
    +            default:
    +                return oai_settings.reasoning_effort;
    +        }
    +    }
    +
    +    const reasoningEffort = resolveReasoningEffort();
    +
    +    // Check if the resolved effort supported by the model
    +    if (oai_settings.chat_completion_source === chat_completion_sources.ELECTRONHUB) {
    +        if (Array.isArray(model_list) && reasoningEffort) {
    +            const currentModel = model_list.find(m => m.id === oai_settings.electronhub_model);
    +            const supportedEfforts = currentModel?.metadata?.supported_reasoning_efforts;
    +            if (Array.isArray(supportedEfforts) && supportedEfforts.includes(reasoningEffort)) {
    +                return reasoningEffort;
    +            }
                 return undefined;
    -        case reasoning_effort_types.min:
    -            return chat_completion_sources.OPENAI === oai_settings.chat_completion_source && /^gpt-5/.test(oai_settings.openai_model)
    -                ? reasoning_effort_types.min
    -                : reasoning_effort_types.low;
    -        case reasoning_effort_types.max:
    -            return reasoning_effort_types.high;
    -        default:
    -            return oai_settings.reasoning_effort;
    +        }
         }
    +
    +    return reasoningEffort;
     }
     
     /**
    @@ -2102,18 +2178,20 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
         const isGroq = oai_settings.chat_completion_source == chat_completion_sources.GROQ;
         const isDeepSeek = oai_settings.chat_completion_source == chat_completion_sources.DEEPSEEK;
         const isAimlapi = oai_settings.chat_completion_source == chat_completion_sources.AIMLAPI;
    +    const isElectronHub = oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB;
         const isXAI = oai_settings.chat_completion_source == chat_completion_sources.XAI;
         const isPollinations = oai_settings.chat_completion_source == chat_completion_sources.POLLINATIONS;
         const isMoonshot = oai_settings.chat_completion_source == chat_completion_sources.MOONSHOT;
    +    const isAzureOpenAI = oai_settings.chat_completion_source == chat_completion_sources.AZURE_OPENAI; // Add this line
         const isTextCompletion = isOAI && textCompletionModels.includes(oai_settings.openai_model);
         const isQuiet = type === 'quiet';
         const isImpersonate = type === 'impersonate';
         const isContinue = type === 'continue';
    -    const stream = oai_settings.stream_openai && !isQuiet && !(isOAI && ['o1-2024-12-17', 'o1'].includes(oai_settings.openai_model));
    +    const stream = oai_settings.stream_openai && !isQuiet && !((isOAI || isAzureOpenAI) && ['o1-2024-12-17', 'o1'].includes(getChatCompletionModel()));
         const useLogprobs = !!power_user.request_token_probabilities;
    -    const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isCustom || isXAI || isAimlapi || isMoonshot);
    +    const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isAzureOpenAI || isCustom || isXAI || isAimlapi || isMoonshot);
     
    -    const logitBiasSources = [chat_completion_sources.OPENAI, chat_completion_sources.OPENROUTER, chat_completion_sources.CUSTOM];
    +    const logitBiasSources = [chat_completion_sources.OPENAI, chat_completion_sources.AZURE_OPENAI, chat_completion_sources.OPENROUTER, chat_completion_sources.CUSTOM];
         if (oai_settings.bias_preset_selected
             && logitBiasSources.includes(oai_settings.chat_completion_source)
             && Array.isArray(oai_settings.bias_presets[oai_settings.bias_preset_selected])
    @@ -2151,6 +2229,16 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
             'custom_prompt_post_processing': oai_settings.custom_prompt_post_processing,
         };
     
    +    if (isAzureOpenAI) {
    +        generate_data.azure_base_url = oai_settings.azure_base_url;
    +        generate_data.azure_deployment_name = oai_settings.azure_deployment_name;
    +        generate_data.azure_api_version = oai_settings.azure_api_version;
    +        // Reasoning effort is not supported on some Azure models (e.g. GPT-3.x, GPT-4.x)
    +        if (/^gpt-[34]/.test(oai_settings.azure_openai_model)) {
    +            delete generate_data.reasoning_effort;
    +        }
    +    }
    +
         if (!canMultiSwipe && ToolManager.canPerformToolCalls(type)) {
             await ToolManager.registerFunctionToolsOpenAI(generate_data);
         }
    @@ -2168,18 +2256,18 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
         }
     
         // Add logprobs request (currently OpenAI only, max 5 on their side)
    -    if (useLogprobs && (isOAI || isCustom || isDeepSeek || isXAI || isAimlapi)) {
    +    if (useLogprobs && (isOAI || isAzureOpenAI || isCustom || isDeepSeek || isXAI || isAimlapi)) {
             generate_data['logprobs'] = 5;
         }
     
         // Remove logit bias/logprobs/stop-strings if not supported by the model
         const isVision = (m) => ['gpt', 'vision'].every(x => m.includes(x));
    -    if (isOAI && isVision(oai_settings.openai_model) || isOpenRouter && isVision(oai_settings.openrouter_model)) {
    +    if ((isOAI && isVision(oai_settings.openai_model)) || (isAzureOpenAI && isVision(oai_settings.azure_openai_model)) || (isOpenRouter && isVision(oai_settings.openrouter_model))) {
             delete generate_data.logit_bias;
             delete generate_data.stop;
             delete generate_data.logprobs;
         }
    -    if (isOAI && oai_settings.openai_model.includes('gpt-4.5') || isOpenRouter && oai_settings.openrouter_model.includes('gpt-4.5')) {
    +    if ((isOAI && oai_settings.openai_model.includes('gpt-4.5')) || (isAzureOpenAI && oai_settings.azure_openai_model.includes('gpt-4.5')) || (isOpenRouter && oai_settings.openrouter_model.includes('gpt-4.5'))) {
             delete generate_data.logprobs;
         }
     
    @@ -2262,16 +2350,6 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
         // https://api-docs.deepseek.com/api/create-chat-completion
         if (isDeepSeek) {
             generate_data.top_p = generate_data.top_p || Number.EPSILON;
    -
    -        if (generate_data.model.endsWith('-reasoner')) {
    -            delete generate_data.top_p;
    -            delete generate_data.temperature;
    -            delete generate_data.frequency_penalty;
    -            delete generate_data.presence_penalty;
    -            delete generate_data.top_logprobs;
    -            delete generate_data.logprobs;
    -            delete generate_data.logit_bias;
    -        }
         }
     
         if (isXAI) {
    @@ -2295,13 +2373,20 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
             delete generate_data.max_tokens;
         }
     
    +    // https://docs.electronhub.ai/api-reference/chat/completions
    +    if (isElectronHub) {
    +        generate_data['top_k'] = Number(oai_settings.top_k_openai);
    +    }
    +
         const seedSupportedSources = [
             chat_completion_sources.OPENAI,
    +        chat_completion_sources.AZURE_OPENAI,
             chat_completion_sources.OPENROUTER,
             chat_completion_sources.MISTRALAI,
             chat_completion_sources.CUSTOM,
             chat_completion_sources.COHERE,
             chat_completion_sources.GROQ,
    +        chat_completion_sources.ELECTRONHUB,
             chat_completion_sources.NANOGPT,
             chat_completion_sources.XAI,
             chat_completion_sources.POLLINATIONS,
    @@ -2313,7 +2398,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
             generate_data['seed'] = oai_settings.seed;
         }
     
    -    if (isOAI && /^(o1|o3|o4)/.test(oai_settings.openai_model)) {
    +    if ((isOAI && /^(o1|o3|o4)/.test(oai_settings.openai_model)) || (isAzureOpenAI && /^(o1|o3|o4)/.test(oai_settings.azure_openai_model))) {
             generate_data.max_completion_tokens = generate_data.max_tokens;
             delete generate_data.max_tokens;
             delete generate_data.logprobs;
    @@ -2336,7 +2421,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
             }
         }
     
    -    if (isOAI && /^gpt-5/.test(oai_settings.openai_model)) {
    +    if ((isOAI && /^gpt-5/.test(oai_settings.openai_model)) || (isAzureOpenAI && /^gpt-5/.test(oai_settings.azure_openai_model))) {
             generate_data.max_completion_tokens = generate_data.max_tokens;
             delete generate_data.max_tokens;
             delete generate_data.logprobs;
    @@ -2466,11 +2551,15 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, ov
             }
             return data.choices?.[0]?.delta?.content || '';
         } else if (chat_completion_source === chat_completion_sources.OPENROUTER) {
    +        const imageUrl = data?.choices?.[0]?.delta?.images?.find(x => x.type === 'image_url')?.image_url?.url;
    +        if (imageUrl) {
    +            state.image = imageUrl;
    +        }
             if (show_thoughts) {
                 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
             }
             return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
    -    } else if ([chat_completion_sources.CUSTOM, chat_completion_sources.POLLINATIONS, chat_completion_sources.AIMLAPI, chat_completion_sources.MOONSHOT, chat_completion_sources.COMETAPI].includes(chat_completion_source)) {
    +    } else if ([chat_completion_sources.CUSTOM, chat_completion_sources.POLLINATIONS, chat_completion_sources.AIMLAPI, chat_completion_sources.MOONSHOT, chat_completion_sources.COMETAPI, chat_completion_sources.ELECTRONHUB].includes(chat_completion_source)) {
             if (show_thoughts) {
                 state.reasoning +=
                     data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
    @@ -2502,6 +2591,7 @@ function parseChatCompletionLogprobs(data) {
     
         switch (oai_settings.chat_completion_source) {
             case chat_completion_sources.OPENAI:
    +        case chat_completion_sources.AZURE_OPENAI:
             case chat_completion_sources.DEEPSEEK:
             case chat_completion_sources.XAI:
             case chat_completion_sources.CUSTOM:
    @@ -2510,7 +2600,7 @@ function parseChatCompletionLogprobs(data) {
                 }
                 // OpenAI Text Completion API is treated as a chat completion source
                 // by SillyTavern, hence its presence in this function.
    -            return textCompletionModels.includes(oai_settings.openai_model)
    +            return textCompletionModels.includes(getChatCompletionModel())
                     ? parseOpenAITextLogprobs(data.choices[0]?.logprobs)
                     : parseOpenAIChatLogprobs(data.choices[0]?.logprobs);
             default:
    @@ -3432,6 +3522,7 @@ function loadOpenAISettings(data, settings) {
         oai_settings.cohere_model = settings.cohere_model ?? default_settings.cohere_model;
         oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model;
         oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model;
    +    oai_settings.electronhub_model = settings.electronhub_model ?? default_settings.electronhub_model;
         oai_settings.nanogpt_model = settings.nanogpt_model ?? default_settings.nanogpt_model;
         oai_settings.deepseek_model = settings.deepseek_model ?? default_settings.deepseek_model;
         oai_settings.aimlapi_model = settings.aimlapi_model ?? default_settings.aimlapi_model;
    @@ -3447,6 +3538,10 @@ function loadOpenAISettings(data, settings) {
         oai_settings.custom_include_headers = settings.custom_include_headers ?? default_settings.custom_include_headers;
         oai_settings.custom_prompt_post_processing = settings.custom_prompt_post_processing ?? default_settings.custom_prompt_post_processing;
         oai_settings.google_model = settings.google_model ?? default_settings.google_model;
    +    oai_settings.azure_base_url = settings.azure_base_url ?? default_settings.azure_base_url;
    +    oai_settings.azure_deployment_name = settings.azure_deployment_name ?? default_settings.azure_deployment_name;
    +    oai_settings.azure_api_version = settings.azure_api_version ?? default_settings.azure_api_version;
    +    oai_settings.azure_openai_model = settings.azure_openai_model ?? default_settings.azure_openai_model;
         oai_settings.vertexai_model = settings.vertexai_model ?? default_settings.vertexai_model;
         oai_settings.chat_completion_source = settings.chat_completion_source ?? default_settings.chat_completion_source;
         oai_settings.show_external_models = settings.show_external_models ?? default_settings.show_external_models;
    @@ -3527,6 +3622,8 @@ function loadOpenAISettings(data, settings) {
         $(`#model_perplexity_select option[value="${oai_settings.perplexity_model}"`).prop('selected', true);
         $('#model_groq_select').val(oai_settings.groq_model);
         $(`#model_groq_select option[value="${oai_settings.groq_model}"`).prop('selected', true);
    +    $('#model_electronhub_select').val(oai_settings.electronhub_model);
    +    $(`#model_electronhub_select option[value="${oai_settings.electronhub_model}"`).prop('selected', true);
         $('#model_nanogpt_select').val(oai_settings.nanogpt_model);
         $(`#model_nanogpt_select option[value="${oai_settings.nanogpt_model}"`).prop('selected', true);
         $('#model_deepseek_select').val(oai_settings.deepseek_model);
    @@ -3541,6 +3638,11 @@ function loadOpenAISettings(data, settings) {
         $(`#model_moonshot_select option[value="${oai_settings.moonshot_model}"`).prop('selected', true);
         $('#custom_model_id').val(oai_settings.custom_model);
         $('#custom_api_url_text').val(oai_settings.custom_url);
    +    $('#azure_base_url').val(oai_settings.azure_base_url);
    +    $('#azure_deployment_name').val(oai_settings.azure_deployment_name);
    +    $('#azure_api_version').val(oai_settings.azure_api_version);
    +    $('#azure_openai_model').val(oai_settings.azure_openai_model);
    +
         $('#openai_max_context').val(oai_settings.openai_max_context);
         $('#openai_max_context_counter').val(`${oai_settings.openai_max_context}`);
         $('#model_openrouter_select').val(oai_settings.openrouter_model);
    @@ -3719,6 +3821,12 @@ async function getStatusOpen() {
             return resultCheckStatus();
         }
     
    +    if (oai_settings.chat_completion_source === chat_completion_sources.AZURE_OPENAI && !isValidUrl(oai_settings.azure_base_url)) {
    +        console.debug('Invalid endpoint URL of Azure OpenAI API:', oai_settings.azure_base_url);
    +        setOnlineStatus(t`Invalid Azure endpoint URL. Requests may fail.`);
    +        return resultCheckStatus();
    +    }
    +
         let data = {
             reverse_proxy: oai_settings.reverse_proxy,
             proxy_password: oai_settings.proxy_password,
    @@ -3744,6 +3852,12 @@ async function getStatusOpen() {
             data.custom_include_headers = oai_settings.custom_include_headers;
         }
     
    +    if (oai_settings.chat_completion_source === chat_completion_sources.AZURE_OPENAI) {
    +        data.azure_base_url = oai_settings.azure_base_url;
    +        data.azure_deployment_name = oai_settings.azure_deployment_name;
    +        data.azure_api_version = oai_settings.azure_api_version;
    +    }
    +
         const canBypass = (oai_settings.chat_completion_source === chat_completion_sources.OPENAI && oai_settings.bypass_status_check) || oai_settings.chat_completion_source === chat_completion_sources.CUSTOM;
         if (canBypass) {
             setOnlineStatus(t`Status check bypassed`);
    @@ -3813,6 +3927,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
             xai_model: settings.xai_model,
             pollinations_model: settings.pollinations_model,
             aimlapi_model: settings.aimlapi_model,
    +        electronhub_model: settings.electronhub_model,
             moonshot_model: settings.moonshot_model,
             fireworks_model: settings.fireworks_model,
             cometapi_model: settings.cometapi_model,
    @@ -3824,6 +3939,10 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
             custom_prompt_post_processing: settings.custom_prompt_post_processing,
             google_model: settings.google_model,
             vertexai_model: settings.vertexai_model,
    +        azure_base_url: settings.azure_base_url,
    +        azure_deployment_name: settings.azure_deployment_name,
    +        azure_api_version: settings.azure_api_version,
    +        azure_openai_model: settings.azure_openai_model,
             temperature: settings.temp_openai,
             frequency_penalty: settings.freq_pen_openai,
             presence_penalty: settings.pres_pen_openai,
    @@ -4578,6 +4697,47 @@ function getFireworksMaxContext(model, isUnlocked) {
         return max_32k;
     }
     
    +/**
    + * Get the maximum context size for the ElectronHub model
    + * @param {string} model Model identifier
    + * @param {boolean} isUnlocked Whether context limits are unlocked
    + * @returns {number} Maximum context size in tokens
    + */
    +function getElectronHubMaxContext(model, isUnlocked) {
    +    if (isUnlocked) {
    +        return unlocked_max;
    +    }
    +
    +    if (Array.isArray(model_list)) {
    +        const modelInfo = model_list.find(m => m.id === model);
    +        if (modelInfo?.tokens) {
    +            return modelInfo.tokens;
    +        }
    +    }
    +    return max_8k;
    +}
    +
    +/**
    + * Get the maximum context size for the NanoGPT model
    + * @param {string} model Model identifier
    + * @param {boolean} isUnlocked Whether context limits are unlocked
    + * @returns {number} Maximum context size in tokens
    + */
    +function getNanoGptMaxContext(model, isUnlocked) {
    +    if (isUnlocked) {
    +        return unlocked_max;
    +    }
    +
    +    if (Array.isArray(model_list)) {
    +        const modelInfo = model_list.find(m => m.id === model);
    +        if (modelInfo?.context_length) {
    +            return modelInfo.context_length;
    +        }
    +    }
    +
    +    return max_128k;
    +}
    +
     async function onModelChange() {
         biasCache = undefined;
         let value = String($(this).val() || '');
    @@ -4668,6 +4828,15 @@ async function onModelChange() {
             oai_settings.groq_model = value;
         }
     
    +    if ($(this).is('#model_electronhub_select')) {
    +        if (!value) {
    +            console.debug('Null ElectronHub model selected. Ignoring.');
    +            return;
    +        }
    +        console.log('ElectronHub model changed to', value);
    +        oai_settings.electronhub_model = value;
    +    }
    +
         if ($(this).is('#model_nanogpt_select')) {
             if (!value) {
                 console.debug('Null NanoGPT model selected. Ignoring.');
    @@ -4736,11 +4905,21 @@ async function onModelChange() {
             oai_settings.cometapi_model = value;
         }
     
    +    if ($(this).is('#azure_openai_model')) {
    +        if (!value) {
    +            console.debug('Null Azure OpenAI model selected. Ignoring.');
    +            return;
    +        }
    +        oai_settings.azure_openai_model = value;
    +    }
    +
         if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(oai_settings.chat_completion_source)) {
             if (oai_settings.max_context_unlocked) {
                 $('#openai_max_context').attr('max', max_2mil);
             } else if (value.includes('gemini-1.5-pro')) {
                 $('#openai_max_context').attr('max', max_2mil);
    +        } else if (value.includes('gemini-2.5-flash-image-preview')) {
    +            $('#openai_max_context').attr('max', max_32k);
             } else if (value.includes('gemini-1.5-flash') || value.includes('gemini-2.0-flash') || value.includes('gemini-2.0-pro') || value.includes('gemini-exp') || value.includes('gemini-2.5-flash') || value.includes('gemini-2.5-pro') || value.includes('learnlm-2.0-flash')) {
                 $('#openai_max_context').attr('max', max_1mil);
             } else if (value.includes('gemma-3-27b-it')) {
    @@ -4808,7 +4987,7 @@ async function onModelChange() {
             $('#temp_openai').attr('max', claude_max_temp).val(oai_settings.temp_openai).trigger('input');
         }
     
    -    if (oai_settings.chat_completion_source == chat_completion_sources.OPENAI) {
    +    if ([chat_completion_sources.AZURE_OPENAI, chat_completion_sources.OPENAI].includes(oai_settings.chat_completion_source)) {
             $('#openai_max_context').attr('max', getMaxContextOpenAI(value));
             oai_settings.openai_max_context = Math.min(oai_settings.openai_max_context, Number($('#openai_max_context').attr('max')));
             $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
    @@ -4907,15 +5086,21 @@ async function onModelChange() {
             $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
         }
     
    -    if (oai_settings.chat_completion_source === chat_completion_sources.NANOGPT) {
    -        if (oai_settings.max_context_unlocked) {
    -            $('#openai_max_context').attr('max', unlocked_max);
    -        } else {
    -            $('#openai_max_context').attr('max', max_128k);
    -        }
    +    if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB) {
    +        const maxContext = getElectronHubMaxContext(oai_settings.electronhub_model, oai_settings.max_context_unlocked);
    +        $('#openai_max_context').attr('max', maxContext);
    +        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');
    +        oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
    +        $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
    +    }
     
    +    if (oai_settings.chat_completion_source === chat_completion_sources.NANOGPT) {
    +        const maxContext = getNanoGptMaxContext(oai_settings.nanogpt_model, oai_settings.max_context_unlocked);
    +        $('#openai_max_context').attr('max', maxContext);
             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');
    +        oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
             $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
         }
     
    @@ -5204,6 +5389,19 @@ async function onConnectButtonClick(e) {
             }
         }
     
    +    if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB) {
    +        const api_key_electronhub = String($('#api_key_electronhub').val()).trim();
    +
    +        if (api_key_electronhub.length) {
    +            await writeSecret(SECRET_KEYS.ELECTRONHUB, api_key_electronhub);
    +        }
    +
    +        if (!secret_state[SECRET_KEYS.ELECTRONHUB]) {
    +            console.log('No secret key saved for Electron Hub');
    +            return;
    +        }
    +    }
    +
         if (oai_settings.chat_completion_source == chat_completion_sources.NANOGPT) {
             const api_key_nanogpt = String($('#api_key_nanogpt').val()).trim();
     
    @@ -5295,6 +5493,20 @@ async function onConnectButtonClick(e) {
             }
         }
     
    +    if (oai_settings.chat_completion_source == chat_completion_sources.AZURE_OPENAI) {
    +        const api_key_azure_openai = String($('#api_key_azure_openai').val()).trim();
    +
    +        if (api_key_azure_openai.length) {
    +            await writeSecret(SECRET_KEYS.AZURE_OPENAI, api_key_azure_openai);
    +        }
    +
    +        if (!api_key_azure_openai && !secret_state[SECRET_KEYS.AZURE_OPENAI]) {
    +            console.log('No secret key saved for Azure OpenAI');
    +            return;
    +        }
    +    }
    +
    +
         startStatusLoading();
         saveSettingsDebounced();
         await getStatusOpen();
    @@ -5338,6 +5550,9 @@ function toggleChatCompletionForms() {
         else if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) {
             $('#model_groq_select').trigger('change');
         }
    +    else if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB) {
    +        $('#model_electronhub_select').trigger('change');
    +    }
         else if (oai_settings.chat_completion_source == chat_completion_sources.NANOGPT) {
             $('#model_nanogpt_select').trigger('change');
         }
    @@ -5365,6 +5580,9 @@ function toggleChatCompletionForms() {
         else if (oai_settings.chat_completion_source == chat_completion_sources.COMETAPI) {
             $('#model_cometapi_select').trigger('change');
         }
    +    else if (oai_settings.chat_completion_source == chat_completion_sources.AZURE_OPENAI) {
    +        $('#azure_openai_model').trigger('change');
    +    }
     
         $('[data-source]').each(function () {
             const validSources = $(this).data('source').split(',');
    @@ -5484,18 +5702,23 @@ export function isImageInliningSupported() {
     
         switch (oai_settings.chat_completion_source) {
             case chat_completion_sources.OPENAI:
    +        case chat_completion_sources.AZURE_OPENAI: {
    +            const modelToCheck = oai_settings.chat_completion_source === chat_completion_sources.AZURE_OPENAI
    +                ? oai_settings.azure_openai_model
    +                : oai_settings.openai_model;
                 return visionSupportedModels.some(model =>
    -                oai_settings.openai_model.includes(model)
    -                && ['gpt-4-turbo-preview', 'o1-mini', 'o3-mini'].some(x => !oai_settings.openai_model.includes(x)),
    +                modelToCheck.includes(model)
    +                && ['gpt-4-turbo-preview', 'o1-mini', 'o3-mini'].some(x => !modelToCheck.includes(x)),
                 );
    +        }
             case chat_completion_sources.MAKERSUITE:
                 return visionSupportedModels.some(model => oai_settings.google_model.includes(model));
             case chat_completion_sources.VERTEXAI:
                 return visionSupportedModels.some(model => oai_settings.vertexai_model.includes(model));
             case chat_completion_sources.CLAUDE:
                 return visionSupportedModels.some(model => oai_settings.claude_model.includes(model));
             case chat_completion_sources.OPENROUTER:
    -            return (Array.isArray(model_list) && model_list.find(m => m.id === oai_settings.openrouter_model)?.architecture?.modality === 'text+image->text');
    +            return (Array.isArray(model_list) && ['text+image->text+image', 'text+image->text'].includes(model_list.find(m => m.id === oai_settings.openrouter_model)?.architecture?.modality));
             case chat_completion_sources.CUSTOM:
                 return true;
             case chat_completion_sources.MISTRALAI:
    @@ -5506,12 +5729,16 @@ export function isImageInliningSupported() {
                 return visionSupportedModels.some(model => oai_settings.xai_model.includes(model));
             case chat_completion_sources.AIMLAPI:
                 return (Array.isArray(model_list) && model_list.find(m => m.id === oai_settings.aimlapi_model)?.features?.includes('openai/chat-completion.vision'));
    +        case chat_completion_sources.ELECTRONHUB:
    +            return (Array.isArray(model_list) && model_list.find(m => m.id === oai_settings.electronhub_model)?.metadata?.vision);
             case chat_completion_sources.POLLINATIONS:
                 return (Array.isArray(model_list) && model_list.find(m => m.id === oai_settings.pollinations_model)?.vision);
             case chat_completion_sources.COMETAPI:
                 return true;
             case chat_completion_sources.MOONSHOT:
                 return visionSupportedModels.some(model => oai_settings.moonshot_model.includes(model));
    +        case chat_completion_sources.NANOGPT:
    +            return (Array.isArray(model_list) && model_list.find(m => m.id === oai_settings.nanogpt_model)?.capabilities?.vision);
             default:
                 return false;
         }
    @@ -6164,6 +6391,21 @@ export function initOpenAI() {
             saveSettingsDebounced();
         });
     
    +    $('#azure_base_url').on('input', function () {
    +        oai_settings.azure_base_url = String($(this).val());
    +        saveSettingsDebounced();
    +    });
    +
    +    $('#azure_deployment_name').on('input', function () {
    +        oai_settings.azure_deployment_name = String($(this).val());
    +        saveSettingsDebounced();
    +    });
    +
    +    $('#azure_api_version').on('input change', function () {
    +        oai_settings.azure_api_version = String($(this).val());
    +        saveSettingsDebounced();
    +    });
    +
         $('#character_names_none').on('input', function () {
             oai_settings.names_behavior = character_names_behavior.NONE;
             setNamesBehaviorControls();
    @@ -6312,6 +6554,7 @@ export function initOpenAI() {
         $('#model_cohere_select').on('change', onModelChange);
         $('#model_perplexity_select').on('change', onModelChange);
         $('#model_groq_select').on('change', onModelChange);
    +    $('#model_electronhub_select').on('change', onModelChange);
         $('#model_nanogpt_select').on('change', onModelChange);
         $('#model_deepseek_select').on('change', onModelChange);
         $('#model_aimlapi_select').on('change', onModelChange);
    @@ -6321,6 +6564,7 @@ export function initOpenAI() {
         $('#model_cometapi_select').on('change', onModelChange);
         $('#model_moonshot_select').on('change', onModelChange);
         $('#model_fireworks_select').on('change', onModelChange);
    +    $('#azure_openai_model').on('change', onModelChange);
         $('#settings_preset_openai').on('change', onSettingsPresetChange);
         $('#new_oai_preset').on('click', onNewPresetClick);
         $('#delete_oai_preset').on('click', onDeletePresetClick);
    
  • public/scripts/reasoning.js+23 0 modified
    @@ -1348,6 +1348,29 @@ function registerReasoningAppEvents() {
         for (const event of [event_types.GENERATION_STOPPED, event_types.GENERATION_ENDED, event_types.CHAT_CHANGED]) {
             eventSource.on(event, () => PromptReasoning.clearLatest());
         }
    +
    +    eventSource.makeFirst(event_types.IMPERSONATE_READY, async () => {
    +        if (!power_user.reasoning.auto_parse) {
    +            return;
    +        }
    +
    +        const sendTextArea = /** @type {HTMLTextAreaElement} */ (document.getElementById('send_textarea'));
    +
    +        if (!sendTextArea) {
    +            console.warn('[Reasoning] Send textarea not found');
    +            return;
    +        }
    +
    +        console.debug('[Reasoning] Auto-parsing reasoning block for impersonation');
    +
    +        if (!sendTextArea.value) {
    +            console.debug('[Reasoning] Reasoning is empty, skipping');
    +            return;
    +        }
    +
    +        sendTextArea.value = removeReasoningFromString(sendTextArea.value);
    +        sendTextArea.dispatchEvent(new Event('input', { bubbles: true }));
    +    });
     }
     
     /**
    
  • public/scripts/RossAscends-mods.js+16 7 modified
    @@ -402,6 +402,7 @@ function RA_autoconnect(PrevApi) {
                         || (secret_state[SECRET_KEYS.COHERE] && oai_settings.chat_completion_source == chat_completion_sources.COHERE)
                         || (secret_state[SECRET_KEYS.PERPLEXITY] && oai_settings.chat_completion_source == chat_completion_sources.PERPLEXITY)
                         || (secret_state[SECRET_KEYS.GROQ] && oai_settings.chat_completion_source == chat_completion_sources.GROQ)
    +                    || (secret_state[SECRET_KEYS.ELECTRONHUB] && oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB)
                         || (secret_state[SECRET_KEYS.NANOGPT] && oai_settings.chat_completion_source == chat_completion_sources.NANOGPT)
                         || (secret_state[SECRET_KEYS.DEEPSEEK] && oai_settings.chat_completion_source == chat_completion_sources.DEEPSEEK)
                         || (secret_state[SECRET_KEYS.XAI] && oai_settings.chat_completion_source == chat_completion_sources.XAI)
    @@ -411,6 +412,7 @@ function RA_autoconnect(PrevApi) {
                         || (secret_state[SECRET_KEYS.COMETAPI] && oai_settings.chat_completion_source == chat_completion_sources.COMETAPI)
                         || (oai_settings.chat_completion_source === chat_completion_sources.POLLINATIONS)
                         || (isValidUrl(oai_settings.custom_url) && oai_settings.chat_completion_source == chat_completion_sources.CUSTOM)
    +                    || (secret_state[SECRET_KEYS.AZURE_OPENAI] && oai_settings.chat_completion_source == chat_completion_sources.AZURE_OPENAI)
                     ) {
                         $('#api_button_openai').trigger('click');
                     }
    @@ -480,8 +482,7 @@ export function dragElement($elmnt) {
     
         let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
         let height, width, top, left, right, bottom,
    -        maxX, maxY, winHeight, winWidth,
    -        topbar;
    +        maxX, maxY, winHeight, winWidth;
     
         const elmntName = $elmnt.attr('id');
         const elmntNameEscaped = $.escapeSelector(elmntName);
    @@ -540,11 +541,6 @@ export function dragElement($elmnt) {
             winWidth = window.innerWidth;
             winHeight = window.innerHeight;
     
    -        topbar = document.getElementById('top-bar');
    -        const topbarstyle = getComputedStyle(topbar);
    -        topBarFirstX = parseInt(topbarstyle.marginInline);
    -        topBarLastY = parseInt(topbarstyle.height);
    -
             // Prepare state object if missing
             if (!power_user.movingUIState[elmntName]) power_user.movingUIState[elmntName] = {};
     
    @@ -1066,6 +1062,19 @@ export function initRossMods() {
                         $('#option_regenerate').trigger('click');
                         $('#options').hide();
                     }
    +
    +                // If there is input text, we do not trigger a regenerate - we just send it
    +                if ($('#send_textarea').val() !== '') {
    +                    if (shouldSendOnEnter()) {
    +                        console.debug('Sending with Ctrl+Enter');
    +                        event.preventDefault();
    +                        sendTextareaMessage();
    +                    } else {
    +                        console.debug('Text area is not empty, but send on enter is disabled');
    +                    }
    +                    return;
    +                }
    +
                     if (skipConfirm) {
                         doRegenerate();
                     } else {
    
  • public/scripts/secrets.js+6 0 modified
    @@ -47,10 +47,12 @@ export const SECRET_KEYS = {
         PERPLEXITY: 'api_key_perplexity',
         GROQ: 'api_key_groq',
         AZURE_TTS: 'api_key_azure_tts',
    +    AZURE_OPENAI: 'api_key_azure_openai',
         FEATHERLESS: 'api_key_featherless',
         HUGGINGFACE: 'api_key_huggingface',
         STABILITY: 'api_key_stability',
         CUSTOM_OPENAI_TTS: 'api_key_custom_openai_tts',
    +    ELECTRONHUB: 'api_key_electronhub',
         NANOGPT: 'api_key_nanogpt',
         TAVILY: 'api_key_tavily',
         BFL: 'api_key_bfl',
    @@ -95,6 +97,7 @@ const FRIENDLY_NAMES = {
         [SECRET_KEYS.GROQ]: 'Groq',
         [SECRET_KEYS.FEATHERLESS]: 'Featherless',
         [SECRET_KEYS.HUGGINGFACE]: 'HuggingFace',
    +    [SECRET_KEYS.ELECTRONHUB]: 'Electron Hub',
         [SECRET_KEYS.NANOGPT]: 'NanoGPT',
         [SECRET_KEYS.GENERIC]: 'Generic (OpenAI-compatible)',
         [SECRET_KEYS.DEEPSEEK]: 'DeepSeek',
    @@ -120,6 +123,7 @@ const FRIENDLY_NAMES = {
         [SECRET_KEYS.MINIMAX_GROUP_ID]: 'MiniMax Group ID',
         [SECRET_KEYS.MOONSHOT]: 'Moonshot AI',
         [SECRET_KEYS.COMETAPI]: 'CometAPI',
    +    [SECRET_KEYS.AZURE_OPENAI]: 'Azure OpenAI',
     };
     
     const INPUT_MAP = {
    @@ -148,6 +152,7 @@ const INPUT_MAP = {
         [SECRET_KEYS.GROQ]: '#api_key_groq',
         [SECRET_KEYS.FEATHERLESS]: '#api_key_featherless',
         [SECRET_KEYS.HUGGINGFACE]: '#api_key_huggingface',
    +    [SECRET_KEYS.ELECTRONHUB]: '#api_key_electronhub',
         [SECRET_KEYS.NANOGPT]: '#api_key_nanogpt',
         [SECRET_KEYS.GENERIC]: '#api_key_generic',
         [SECRET_KEYS.DEEPSEEK]: '#api_key_deepseek',
    @@ -157,6 +162,7 @@ const INPUT_MAP = {
         [SECRET_KEYS.MOONSHOT]: '#api_key_moonshot',
         [SECRET_KEYS.FIREWORKS]: '#api_key_fireworks',
         [SECRET_KEYS.COMETAPI]: '#api_key_cometapi',
    +    [SECRET_KEYS.AZURE_OPENAI]: '#api_key_azure_openai',
     };
     
     const getLabel = () => moment().format('L LT');
    
  • public/scripts/slash-commands.js+504 512 modified
  • public/scripts/slash-commands/SlashCommand.js+14 15 modified
    @@ -7,13 +7,12 @@ import { SlashCommandDebugController } from './SlashCommandDebugController.js';
     import { SlashCommandScope } from './SlashCommandScope.js';
     
     /**
    - * @typedef {{
    + * @typedef {NamedArgumentsCapture & {
      * _scope:SlashCommandScope,
      * _parserFlags:import('./SlashCommandParser.js').ParserFlags,
      * _abortController:SlashCommandAbortController,
      * _debugController:SlashCommandDebugController,
      * _hasUnnamedArgument:boolean,
    - * [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined,
      * }} NamedArguments
      */
     
    @@ -52,7 +51,7 @@ export class SlashCommand {
     
     
         /**@type {string}*/ name;
    -    /**@type {(namedArguments:{_scope:SlashCommandScope, _abortController:SlashCommandAbortController, [id:string]:string|SlashCommandClosure}, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
    +    /**@type {(namedArguments:NamedArguments, unnamedArguments:UnnamedArguments)=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
         /**@type {string}*/ helpString;
         /**@type {boolean}*/ splitUnnamedArgument = false;
         /**@type {Number}*/ splitUnnamedArgumentCount;
    @@ -238,7 +237,7 @@ export class SlashCommand {
                         const name = document.createElement('div'); {
                             name.classList.add('name');
                             name.classList.add('monospace');
    -                        name.title = 'command name';
    +                        name.title = t`Command name`;
                             name.textContent = `/${key}`;
                             head.append(name);
                         }
    @@ -284,19 +283,19 @@ export class SlashCommand {
                                         const argItem = document.createElement('div'); {
                                             argItem.classList.add('argument');
                                             argItem.classList.add('namedArgument');
    -                                        argItem.title = `${arg.isRequired ? '' : 'optional '}named argument`;
    +                                        argItem.title = arg.isRequired ? t`Named argument` : t`Optional named argument`;
                                             if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
                                             if (arg.acceptsMultiple) argItem.classList.add('multiple');
                                             const name = document.createElement('span'); {
                                                 name.classList.add('argument-name');
    -                                            name.title = `${argItem.title} - name`;
    +                                            name.title = t`${argItem.title} - Name`;
                                                 name.textContent = arg.name;
                                                 argItem.append(name);
                                             }
                                             if (arg.enumList.length > 0) {
                                                 const enums = document.createElement('span'); {
                                                     enums.classList.add('argument-enums');
    -                                                enums.title = `${argItem.title} - accepted values`;
    +                                                enums.title = t`${argItem.title} - Accepted values`;
                                                     for (const e of arg.enumList) {
                                                         const enumItem = document.createElement('span'); {
                                                             enumItem.classList.add('argument-enum');
    @@ -309,7 +308,7 @@ export class SlashCommand {
                                             } else {
                                                 const types = document.createElement('span'); {
                                                     types.classList.add('argument-types');
    -                                                types.title = `${argItem.title} - accepted types`;
    +                                                types.title = t`${argItem.title} - Accepted types`;
                                                     for (const t of arg.typeList) {
                                                         const type = document.createElement('span'); {
                                                             type.classList.add('argument-type');
    @@ -325,7 +324,7 @@ export class SlashCommand {
                                         if (arg.defaultValue !== null) {
                                             const argDefault = document.createElement('div'); {
                                                 argDefault.classList.add('argument-default');
    -                                            argDefault.title = 'default value';
    +                                            argDefault.title = t`Default value`;
                                                 argDefault.textContent = arg.defaultValue.toString();
                                                 argSpec.append(argDefault);
                                             }
    @@ -348,13 +347,13 @@ export class SlashCommand {
                                         const argItem = document.createElement('div'); {
                                             argItem.classList.add('argument');
                                             argItem.classList.add('unnamedArgument');
    -                                        argItem.title = `${arg.isRequired ? '' : 'optional '}unnamed argument`;
    +                                        argItem.title = arg.isRequired ? t`Unnamed argument` : t`Optional unnamed argument`;
                                             if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
                                             if (arg.acceptsMultiple) argItem.classList.add('multiple');
                                             if (arg.enumList.length > 0) {
                                                 const enums = document.createElement('span'); {
                                                     enums.classList.add('argument-enums');
    -                                                enums.title = `${argItem.title} - accepted values`;
    +                                                enums.title = t`${argItem.title} - Accepted values`;
                                                     for (const e of arg.enumList) {
                                                         const enumItem = document.createElement('span'); {
                                                             enumItem.classList.add('argument-enum');
    @@ -367,7 +366,7 @@ export class SlashCommand {
                                             } else {
                                                 const types = document.createElement('span'); {
                                                     types.classList.add('argument-types');
    -                                                types.title = `${argItem.title} - accepted types`;
    +                                                types.title = t`${argItem.title} - Accepted types`;
                                                     for (const t of arg.typeList) {
                                                         const type = document.createElement('span'); {
                                                             type.classList.add('argument-type');
    @@ -383,7 +382,7 @@ export class SlashCommand {
                                         if (arg.defaultValue !== null) {
                                             const argDefault = document.createElement('div'); {
                                                 argDefault.classList.add('argument-default');
    -                                            argDefault.title = 'default value';
    +                                            argDefault.title = t`Default value`;
                                                 argDefault.textContent = arg.defaultValue.toString();
                                                 argSpec.append(argDefault);
                                             }
    @@ -402,7 +401,7 @@ export class SlashCommand {
                         }
                         const returns = document.createElement('span'); {
                             returns.classList.add('returns');
    -                        returns.title = [null, undefined, 'void'].includes(returnType) ? 'command does not return anything' : 'return value';
    +                        returns.title = [null, undefined, 'void'].includes(returnType) ? t`Command does not return anything` : t`Return value`;
                             returns.textContent = returnType ?? 'void';
                             body.append(returns);
                         }
    @@ -415,7 +414,7 @@ export class SlashCommand {
                     help.innerHTML = helpString;
                     for (const code of help.querySelectorAll('pre > code')) {
                         code.classList.add('language-stscript');
    -                    hljs.highlightElement(code);
    +                    hljs.highlightElement(/**@type {HTMLElement}*/(code));
                     }
                     frag.append(help);
                 }
    
  • public/scripts/textgen-models.js+2 0 modified
    @@ -74,12 +74,14 @@ const OPENROUTER_PROVIDERS = [
         'NextBit',
         'Nineteen',
         'Novita',
    +    'Nvidia',
         'OpenAI',
         'OpenInference',
         'Parasail',
         'Perplexity',
         'Phala',
         'SambaNova',
    +    'SiliconFlow',
         'Stealth',
         'Switchpoint',
         'Targon',
    
  • public/scripts/tokenizers.js+4 0 modified
    @@ -586,6 +586,10 @@ export function getTokenizerModel() {
         const nemoTokenizer = 'nemo';
         const deepseekTokenizer = 'deepseek';
     
    +    if (oai_settings.chat_completion_source == chat_completion_sources.AZURE_OPENAI) {
    +        return oai_settings.azure_openai_model || turboTokenizer;
    +    }
    +
         if (oai_settings.chat_completion_source == chat_completion_sources.DEEPSEEK) {
             return deepseekTokenizer;
         }
    
  • public/scripts/tool-calling.js+9 0 modified
    @@ -633,6 +633,13 @@ export class ToolManager {
                 }
             }
     
    +        if (oai_settings.chat_completion_source === chat_completion_sources.ELECTRONHUB && Array.isArray(model_list)) {
    +            const currentModel = model_list.find(model => model.id === oai_settings.electronhub_model);
    +            if (currentModel && currentModel.metadata?.function_call) {
    +                return currentModel.metadata.function_call;
    +            }
    +        }
    +
             const supportedSources = [
                 chat_completion_sources.OPENAI,
                 chat_completion_sources.CUSTOM,
    @@ -651,6 +658,8 @@ export class ToolManager {
                 chat_completion_sources.MOONSHOT,
                 chat_completion_sources.FIREWORKS,
                 chat_completion_sources.COMETAPI,
    +            chat_completion_sources.ELECTRONHUB,
    +            chat_completion_sources.AZURE_OPENAI,
             ];
             return supportedSources.includes(oai_settings.chat_completion_source);
         }
    
  • public/scripts/world-info.js+6 6 modified
    @@ -2348,21 +2348,21 @@ async function displayWorldEntries(name, data, navigation = navigation_option.no
             const entryCount = Object.keys(data.entries).length;
             const moreThan100 = entryCount > 100;
     
    -        let content = '<span>Apply your current sorting to the "Order" field. The Order values will go down from the chosen number.</span>';
    +        let content = '<span>' + t`Apply your current sorting to the "Order" field. The Order values will go down from the chosen number.` + '</span>';
             if (moreThan100) {
    -            content += `<div class="m-t-1"><i class="fa-solid fa-triangle-exclamation" style="color: #FFD43B;"></i> More than 100 entries in this world. If you don't choose a number higher than that, the lower entries will default to 0.<br />(Usual default: 100)<br />Minimum: ${entryCount}</div>`;
    +            content += '<div class="m-t-1"><i class="fa-solid fa-triangle-exclamation" style="color: #FFD43B;"></i> ' + t`More than 100 entries in this world. If you don't choose a number higher than that, the lower entries will default to 0.<br />(Usual default: 100)<br />Minimum: ${entryCount}` + '</div>';
             }
     
    -        const result = await Popup.show.input('Apply Current Sorting', content, '100', { okButton: 'Apply', cancelButton: 'Cancel' });
    +        const result = await Popup.show.input(t`Apply Current Sorting`, content, '100', { okButton: t`Apply`, cancelButton: 'Cancel' });
             if (!result) return;
     
             const start = Number(result);
             if (isNaN(start) || start < 0) {
    -            toastr.error('Invalid number: ' + result, 'Apply Current Sorting');
    +            toastr.error(t`Invalid number: ${result}`, t`Apply Current Sorting`);
                 return;
             }
             if (start < entryCount) {
    -            toastr.warning('A number lower than the entry count has been chosen. All entries below that will default to 0.', 'Apply Current Sorting');
    +            toastr.warning(t`A number lower than the entry count has been chosen. All entries below that will default to 0.`, t`Apply Current Sorting`);
             }
     
             // We need to sort the entries here, as the data source isn't sorted
    @@ -3728,7 +3728,7 @@ export async function deleteWorldInfoEntry(data, uid, { silent = false } = {}) {
             return;
         }
     
    -    const confirmation = silent || await Popup.show.confirm(`Delete the entry with UID: ${uid}?`, 'This action is irreversible!');
    +    const confirmation = silent || await Popup.show.confirm(t`Delete the entry with UID: ${uid}?`, t`This action is irreversible!`);
         if (!confirmation) {
             return false;
         }
    
  • public/style.css+2 172 modified
    @@ -13,6 +13,7 @@
     @import url(css/welcome.css);
     @import url(css/data-maid.css);
     @import url(css/secrets.css);
    +@import url(css/backgrounds.css);
     
     :root {
         interpolate-size: allow-keywords;
    @@ -721,71 +722,11 @@ hr {
         opacity: 0.2;
     }
     
    -#bg1,
    -#bg_custom {
    -    background-repeat: no-repeat;
    -    background-attachment: fixed;
    -    background-size: cover;
    -    position: absolute;
    -    width: 100%;
    -    height: 100%;
    -    transition: background-image var(--animation-duration-3x) ease-in-out;
    -}
    -
    -/* Background fitting options */
    -#background_fitting {
    -    max-width: 6em;
    -}
    -
    -/* Fill/Cover - scales to fill width while maintaining aspect ratio */
    -#bg1.cover,
    -#bg_custom.cover {
    -    background-size: cover;
    -    background-position: center;
    -}
    -
    -/* Fit/Contain - shows entire image maintaining aspect ratio */
    -#bg1.contain,
    -#bg_custom.contain {
    -    background-size: contain;
    -    background-position: center;
    -    background-repeat: no-repeat;
    -}
    -
    -/* Stretch - stretches to fill entire space */
    -#bg1.stretch,
    -#bg_custom.stretch {
    -    background-size: 100% 100%;
    -}
    -
    -/* Center - centers without scaling */
    -#bg1.center,
    -#bg_custom.center {
    -    background-size: auto;
    -    background-position: center;
    -    background-repeat: no-repeat;
    -}
    -
    -body.reduced-motion #bg1,
    -body.reduced-motion #bg_custom {
    -    transition: none;
    -}
    -
     #version_display {
         padding: 5px;
         opacity: 0.8;
     }
     
    -#bg1 {
    -    background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=');
    -    z-index: -3;
    -}
    -
    -#bg_custom {
    -    background-image: none;
    -    z-index: -2;
    -}
    -
     /*TOPPER margin*/
     
     #top-bar {
    @@ -3298,123 +3239,12 @@ input[type=search]:focus::-webkit-search-cancel-button {
     .bg_list {
         display: flex;
         flex-wrap: wrap;
    -    width: calc(var(--sheldWidth) - 10px);
    +    width: calc(var(--sheldWidth) - 15px);
         max-width: 100vw;
         max-width: 100dvw;
         justify-content: space-evenly;
     }
     
    -.bg_example {
    -    width: 30%;
    -    max-width: 200px;
    -    background-repeat: no-repeat;
    -    background-size: cover;
    -    background-position: center;
    -    border-radius: 10px;
    -    border: 1px solid var(--SmartThemeBorderColor);
    -    box-shadow: 0 0 7px var(--black50a);
    -    margin: 5px;
    -    cursor: pointer;
    -    aspect-ratio: 16/9;
    -    justify-content: flex-end;
    -    position: relative;
    -}
    -
    -.bg_example.locked {
    -    outline: 2px solid var(--golden);
    -}
    -
    -.bg_example:hover.locked .bg_example_lock,
    -.bg_example:focus-within.locked .bg_example_lock {
    -    display: none;
    -}
    -
    -.bg_example:hover:not(.locked) .bg_example_unlock,
    -.bg_example:focus-within:not(.locked) .bg_example_unlock {
    -    display: none;
    -}
    -
    -.bg_example:hover[custom="true"] .bg_example_edit,
    -.bg_example:focus-within[custom="true"] .bg_example_edit {
    -    display: none;
    -}
    -
    -.bg_example:hover[custom="false"] .bg_example_copy,
    -.bg_example:focus-within[custom="false"] .bg_example_copy {
    -    display: none;
    -}
    -
    -.BGSampleTitle {
    -    display: flex;
    -    width: 100%;
    -    height: min-content;
    -    text-align: center;
    -    justify-content: center;
    -    align-self: flex-end;
    -    bottom: 0;
    -    position: relative;
    -    word-break: break-word;
    -    background-color: var(--SmartThemeBlurTintColor);
    -    font-size: calc(var(--fontScale) * 0.9em);
    -    max-height: 50%;
    -    overflow-y: clip;
    -    border-radius: 0 0 7px 7px;
    -}
    -
    -.bg_example[custom="true"] .BGSampleTitle {
    -    display: none;
    -}
    -
    -.bg_button {
    -    padding: 4px;
    -    position: absolute;
    -    top: 5px;
    -    cursor: pointer;
    -    opacity: 0.8;
    -    border-radius: 3px;
    -    font-size: 20px;
    -    color: var(--black70a);
    -    text-shadow: none;
    -    margin: 0;
    -    filter: drop-shadow(0px 0px 3px white);
    -    transition: opacity var(--animation-duration-2x) ease-in-out;
    -    display: none;
    -}
    -
    -.bg_example:hover .bg_button,
    -.bg_example:focus-within .bg_button {
    -    display: block;
    -}
    -
    -.bg_button:hover {
    -    opacity: 1;
    -}
    -
    -.bg_example_cross {
    -    right: 6px;
    -}
    -
    -.bg_example_edit {
    -    left: 6px;
    -}
    -
    -.bg_example_copy {
    -    left: 6px;
    -}
    -
    -.bg_example_lock,
    -.bg_example_unlock {
    -    left: 50%;
    -    transform: translateX(-50%);
    -}
    -
    -.add_bg_but {
    -    cursor: pointer;
    -    opacity: 0.1;
    -    height: 100%;
    -    width: 100%;
    -}
    -
     .input-file {
         display: flex;
         justify-content: center;
    
  • src/command-line.js+8 0 modified
    @@ -27,6 +27,7 @@ import { initConfig } from './config-init.js';
      * @property {boolean} ssl If enable SSL
      * @property {string} certPath Path to certificate
      * @property {string} keyPath Path to private key
    + * @property {string} keyPassphrase SSL private key passphrase
      * @property {boolean} whitelistMode If enable whitelist mode
      * @property {boolean} basicAuthMode If enable basic authentication
      * @property {boolean} requestProxyEnabled If enable outgoing request proxy
    @@ -70,6 +71,7 @@ export class CommandLineParser {
                 ssl: false,
                 certPath: 'certs/cert.pem',
                 keyPath: 'certs/privkey.pem',
    +            keyPassphrase: '',
                 whitelistMode: true,
                 basicAuthMode: false,
                 requestProxyEnabled: false,
    @@ -193,6 +195,11 @@ export class CommandLineParser {
                     default: null,
                     describe: 'Path to SSL private key file',
                 })
    +            .option('keyPassphrase', {
    +                type: 'string',
    +                default: null,
    +                describe: 'Passphrase for the SSL private key',
    +            })
                 .option('whitelist', {
                     type: 'boolean',
                     default: null,
    @@ -291,6 +298,7 @@ export class CommandLineParser {
                 ssl: cliArguments.ssl ?? getConfigValue('ssl.enabled', defaultConfig.ssl, 'boolean'),
                 certPath: cliArguments.certPath ?? getConfigValue('ssl.certPath', defaultConfig.certPath),
                 keyPath: cliArguments.keyPath ?? getConfigValue('ssl.keyPath', defaultConfig.keyPath),
    +            keyPassphrase: cliArguments.keyPassphrase ?? getConfigValue('ssl.keyPassphrase', defaultConfig.keyPassphrase),
                 whitelistMode: cliArguments.whitelist ?? getConfigValue('whitelistMode', defaultConfig.whitelistMode, 'boolean'),
                 basicAuthMode: cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', defaultConfig.basicAuthMode, 'boolean'),
                 requestProxyEnabled: cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', defaultConfig.requestProxyEnabled, 'boolean'),
    
  • src/constants.js+41 0 modified
    @@ -173,6 +173,7 @@ export const CHAT_COMPLETION_SOURCES = {
         COHERE: 'cohere',
         PERPLEXITY: 'perplexity',
         GROQ: 'groq',
    +    ELECTRONHUB: 'electronhub',
         NANOGPT: 'nanogpt',
         DEEPSEEK: 'deepseek',
         AIMLAPI: 'aimlapi',
    @@ -181,6 +182,7 @@ export const CHAT_COMPLETION_SOURCES = {
         MOONSHOT: 'moonshot',
         FIREWORKS: 'fireworks',
         COMETAPI: 'cometapi',
    +    AZURE_OPENAI: 'azure_openai',
     };
     
     /**
    @@ -407,6 +409,45 @@ export const VLLM_KEYS = [
         'guided_whitespace_pattern',
     ];
     
    +export const AZURE_OPENAI_KEYS = [
    +    'messages',
    +    'temperature',
    +    'frequency_penalty',
    +    'presence_penalty',
    +    'top_p',
    +    'max_tokens',
    +    'max_completion_tokens',
    +    'stream',
    +    'logit_bias',
    +    'stop',
    +    'n',
    +    'logprobs',
    +    'seed',
    +    'tools',
    +    'tool_choice',
    +    'reasoning_effort',
    +];
    +
    +export const OPENAI_REASONING_EFFORT_MODELS = [
    +    'o1',
    +    'o3-mini',
    +    'o3-mini-2025-01-31',
    +    'o4-mini',
    +    'o4-mini-2025-04-16',
    +    'o3',
    +    'o3-2025-04-16',
    +    'gpt-5',
    +    'gpt-5-2025-08-07',
    +    'gpt-5-mini',
    +    'gpt-5-mini-2025-08-07',
    +    'gpt-5-nano',
    +    'gpt-5-nano-2025-08-07',
    +];
    +
    +export const OPENAI_REASONING_EFFORT_MAP = {
    +    min: 'minimal',
    +};
    +
     export const LOG_LEVELS = {
         DEBUG: 0,
         INFO: 1,
    
  • src/endpoints/backends/chat-completions.js+363 56 modified
    @@ -2,11 +2,15 @@ import process from 'node:process';
     import util from 'node:util';
     import express from 'express';
     import fetch from 'node-fetch';
    +import urlJoin from 'url-join';
     
     import {
         AIMLAPI_HEADERS,
    +    AZURE_OPENAI_KEYS,
         CHAT_COMPLETION_SOURCES,
         GEMINI_SAFETY,
    +    OPENAI_REASONING_EFFORT_MAP,
    +    OPENAI_REASONING_EFFORT_MODELS,
         OPENROUTER_HEADERS,
     } from '../../constants.js';
     import {
    @@ -60,6 +64,7 @@ const API_GROQ = 'https://api.groq.com/openai/v1';
     const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
     const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
     const API_AI21 = 'https://api.ai21.com/studio/v1';
    +const API_ELECTRONHUB = 'https://api.electronhub.ai/v1';
     const API_NANOGPT = 'https://nano-gpt.com/api/v1';
     const API_DEEPSEEK = 'https://api.deepseek.com/beta';
     const API_XAI = 'https://api.x.ai/v1';
    @@ -221,7 +226,7 @@ async function sendClaudeRequest(request, response) {
                 betaHeaders.push('extended-cache-ttl-2025-04-11');
             }
     
    -        if (isOpus41){
    +        if (isOpus41) {
                 if (requestBody.top_p < 1) {
                     delete requestBody.temperature;
                 } else {
    @@ -371,6 +376,7 @@ async function sendMakerSuiteRequest(request, response) {
                 'gemini-2.0-flash-exp',
                 'gemini-2.0-flash-exp-image-generation',
                 'gemini-2.0-flash-preview-image-generation',
    +            'gemini-2.5-flash-image-preview',
             ];
     
             // These models do not support setting the threshold to OFF at all.
    @@ -381,7 +387,7 @@ async function sendMakerSuiteRequest(request, response) {
                 'gemini-1.5-flash-8b-exp-0924',
             ];
     
    -        const isThinkingConfigModel = m => /^gemini-2.5-(flash|pro)/.test(m);
    +        const isThinkingConfigModel = m => /^gemini-2.5-(flash|pro)/.test(m) && !/-image-preview$/.test(m);
     
             const noSearchModels = [
                 'gemini-2.0-flash-lite',
    @@ -496,8 +502,8 @@ async function sendMakerSuiteRequest(request, response) {
                         ? 'https://aiplatform.googleapis.com'
                         : `https://${region}-aiplatform.googleapis.com`;
                     url = projectId
    -                    ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent?key=${keyParam}${stream ? '&alt=sse' : ''}`
    -                    : `${baseUrl}/v1/publishers/google/models/${model}:generateContent?key=${keyParam}${stream ? '&alt=sse' : ''}`;
    +                    ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${responseType}?key=${keyParam}${stream ? '&alt=sse' : ''}`
    +                    : `${baseUrl}/v1/publishers/google/models/${model}:${responseType}?key=${keyParam}${stream ? '&alt=sse' : ''}`;
                 } else if (authType === 'full') {
                     // For Full mode (service account authentication), use project-specific URL
                     // Get project ID from Service Account JSON
    @@ -916,10 +922,7 @@ async function sendDeepSeekRequest(request, response) {
                 request.body.messages.push(message);
             }
     
    -        const postProcessType = String(request.body.model).endsWith('-reasoner')
    -            ? PROMPT_PROCESSING_TYPE.STRICT_TOOLS
    -            : PROMPT_PROCESSING_TYPE.SEMI_TOOLS;
    -        const processedMessages = addAssistantPrefix(postProcessPrompt(request.body.messages, postProcessType, getPromptNames(request)), bodyParams.tools, 'prefix');
    +        const processedMessages = addAssistantPrefix(postProcessPrompt(request.body.messages, PROMPT_PROCESSING_TYPE.SEMI_TOOLS, getPromptNames(request)), bodyParams.tools, 'prefix');
     
             const requestBody = {
                 'messages': processedMessages,
    @@ -1194,14 +1197,209 @@ async function sendAimlapiRequest(request, response) {
         }
     }
     
    +/**
    + * Sends a request to Electron Hub.
    + * @param {express.Request} request Express request
    + * @param {express.Response} response Express response
    + */
    +async function sendElectronHubRequest(request, response) {
    +    const apiUrl = API_ELECTRONHUB;
    +    const apiKey = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
    +
    +    if (!apiKey) {
    +        console.warn('Electron Hub key is missing.');
    +        return response.status(400).send({ error: true });
    +    }
    +
    +    const controller = new AbortController();
    +    request.socket.removeAllListeners('close');
    +    request.socket.on('close', function () {
    +        controller.abort();
    +    });
    +
    +    try {
    +        let bodyParams = {};
    +
    +        if (request.body.enable_web_search) {
    +            bodyParams['web_search'] = true;
    +        }
    +
    +        if (Array.isArray(request.body.tools) && request.body.tools.length > 0) {
    +            bodyParams['tools'] = request.body.tools;
    +            bodyParams['tool_choice'] = request.body.tool_choice;
    +        }
    +
    +        if (request.body.reasoning_effort) {
    +            bodyParams['reasoning_effort'] = request.body.reasoning_effort;
    +        }
    +
    +        if (request.body.json_schema) {
    +            bodyParams['response_format'] = {
    +                type: 'json_schema',
    +                json_schema: {
    +                    name: request.body.json_schema.name,
    +                    description: request.body.json_schema.description,
    +                    schema: request.body.json_schema.value,
    +                    strict: request.body.json_schema.strict ?? true,
    +                },
    +            };
    +        }
    +
    +        const requestBody = {
    +            'messages': request.body.messages,
    +            'model': request.body.model,
    +            'temperature': request.body.temperature,
    +            'max_tokens': request.body.max_tokens,
    +            'stream': request.body.stream,
    +            'presence_penalty': request.body.presence_penalty,
    +            'frequency_penalty': request.body.frequency_penalty,
    +            'top_p': request.body.top_p,
    +            'top_k': request.body.top_k,
    +            'seed': request.body.seed,
    +            ...bodyParams,
    +        };
    +
    +        const config = {
    +            method: 'POST',
    +            headers: {
    +                'Content-Type': 'application/json',
    +                'Authorization': 'Bearer ' + apiKey,
    +            },
    +            body: JSON.stringify(requestBody),
    +            signal: controller.signal,
    +        };
    +
    +        console.debug('Electron Hub request:', requestBody);
    +
    +        const generateResponse = await fetch(apiUrl + '/chat/completions', config);
    +
    +        if (request.body.stream) {
    +            forwardFetchResponse(generateResponse, response);
    +        } else {
    +            if (!generateResponse.ok) {
    +                const errorText = await generateResponse.text();
    +                console.warn('Electron Hub returned error: ', errorText);
    +                const errorJson = tryParse(errorText) ?? { error: true };
    +                return response.status(500).send(errorJson);
    +            }
    +            const generateResponseJson = await generateResponse.json();
    +            console.debug('Electron Hub response:', generateResponseJson);
    +            return response.send(generateResponseJson);
    +        }
    +    }
    +    catch (error) {
    +        console.error('Error communicating with Electron Hub: ', error);
    +        if (!response.headersSent) {
    +            response.send({ error: true });
    +        } else {
    +            response.end();
    +        }
    +    }
    +}
    +
    +/**
    + * Sends a chat completion request to Azure OpenAI.
    + * @param {express.Request} request Express request object (contains request.body with all generate_data)
    + * @param {express.Response} response Express response object
    + */
    +async function sendAzureOpenAIRequest(request, response) {
    +    // 1. GATHER & VALIDATE SETTINGS
    +    const { azure_base_url, azure_deployment_name, azure_api_version } = request.body;
    +    const apiKey = readSecret(request.user.directories, SECRET_KEYS.AZURE_OPENAI);
    +    if (!azure_base_url || !azure_deployment_name || !azure_api_version || !apiKey) {
    +        return response.status(400).send({
    +            error: {
    +                message: 'Azure OpenAI configuration is incomplete. Please provide Base URL, Deployment Name, API Version, and API Key in the connection settings.',
    +            },
    +        });
    +    }
    +
    +    // 2. PREPARE THE REQUEST
    +    const url = new URL(`/openai/deployments/${azure_deployment_name}/chat/completions`, azure_base_url);
    +    url.searchParams.set('api-version', azure_api_version);
    +    const endpointUrl = url.toString();
    +
    +    // Create the base payload with all standard parameters
    +    const apiRequestBody = /** @type {any} */ ({});
    +    for (const key of AZURE_OPENAI_KEYS) {
    +        if (Object.hasOwn(request.body, key)) {
    +            apiRequestBody[key] = request.body[key];
    +        }
    +    }
    +
    +    // Handle Structured Output (JSON Mode) by translating the custom `json_schema` object.
    +    if (request.body.json_schema) {
    +        apiRequestBody['response_format'] = {
    +            type: 'json_schema',
    +            json_schema: {
    +                name: request.body.json_schema.name,
    +                strict: request.body.json_schema.strict ?? true,
    +                schema: request.body.json_schema.value,
    +            },
    +        };
    +    }
    +
    +    // Adjust logprobs for Azure OpenAI, which follows the OpenAI Chat Completions API spec.
    +    if (typeof apiRequestBody.logprobs === 'number' && apiRequestBody.logprobs > 0) {
    +        apiRequestBody.top_logprobs = apiRequestBody.logprobs;
    +        apiRequestBody.logprobs = true;
    +    }
    +
    +    // Do not send reasoning effort to models which do not support it
    +    apiRequestBody['reasoning_effort'] = OPENAI_REASONING_EFFORT_MODELS.includes(request.body.model)
    +        ? OPENAI_REASONING_EFFORT_MAP[request.body.reasoning_effort] ?? request.body.reasoning_effort
    +        : undefined;
    +
    +    const controller = new AbortController();
    +    request.socket.removeAllListeners('close');
    +    request.socket.on('close', () => controller.abort());
    +
    +    const config = {
    +        method: 'POST',
    +        headers: {
    +            'Content-Type': 'application/json',
    +            'api-key': apiKey,
    +        },
    +        body: JSON.stringify(apiRequestBody),
    +        signal: controller.signal,
    +    };
    +
    +    console.info(`Sending request to Azure OpenAI: ${endpointUrl}`);
    +    console.debug('Azure OpenAI Request Body:', apiRequestBody);
    +    try {
    +        const fetchResponse = await fetch(endpointUrl, config);
    +
    +        if (request.body.stream) {
    +            return forwardFetchResponse(fetchResponse, response);
    +        }
    +
    +        if (fetchResponse.ok) {
    +            /** @type {any} */
    +            const json = await fetchResponse.json();
    +            console.debug('Azure OpenAI response:', json);
    +            return response.send(json);
    +        }
    +
    +        const text = await fetchResponse.text();
    +        const data = tryParse(text) || { error: { message: fetchResponse.statusText || 'Unknown error occurred' } };
    +        return response.status(500).send(data);
    +    } catch (error) {
    +        const message = error.name === 'AbortError'
    +            ? 'Request was aborted by the client.'
    +            : (error.message || 'An unknown network error occurred.');
    +        return response.status(500).send({ error: { message, ...error } });
    +    }
    +}
    +
     export const router = express.Router();
     
     router.post('/status', async function (request, statusResponse) {
         if (!request.body) return statusResponse.sendStatus(400);
     
    -    let apiUrl;
    -    let apiKey;
    -    let headers;
    +    let apiUrl = '';
    +    let apiKey = '';
    +    let headers = {};
    +    let queryParams = {};
     
         if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENAI) {
             apiUrl = new URL(request.body.reverse_proxy || API_OPENAI).toString();
    @@ -1225,16 +1423,21 @@ router.post('/status', async function (request, statusResponse) {
             apiUrl = API_COHERE_V1;
             apiKey = readSecret(request.user.directories, SECRET_KEYS.COHERE);
             headers = {};
    +    } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.ELECTRONHUB) {
    +        apiUrl = API_ELECTRONHUB;
    +        apiKey = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
    +        headers = {};
         } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.NANOGPT) {
             apiUrl = API_NANOGPT;
             apiKey = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
             headers = {};
    +        queryParams = { detailed: true };
         } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.DEEPSEEK) {
    -        apiUrl = new URL(request.body.reverse_proxy || API_DEEPSEEK.replace('/beta', ''));
    +        apiUrl = new URL(request.body.reverse_proxy || API_DEEPSEEK.replace('/beta', '')).toString();
             apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.DEEPSEEK);
             headers = {};
         } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.XAI) {
    -        apiUrl = new URL(request.body.reverse_proxy || API_XAI);
    +        apiUrl = new URL(request.body.reverse_proxy || API_XAI).toString();
             apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.XAI);
             headers = {};
         } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.AIMLAPI) {
    @@ -1298,6 +1501,84 @@ router.post('/status', async function (request, statusResponse) {
                 console.error('Error fetching Google AI Studio models:', error);
                 return statusResponse.send({ error: true, bypass: true, data: { data: [] } });
             }
    +    } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.AZURE_OPENAI) {
    +        const { azure_base_url, azure_deployment_name, azure_api_version } = request.body;
    +        const apiKey = readSecret(request.user.directories, SECRET_KEYS.AZURE_OPENAI);
    +
    +        // 1) Validate configuration from the frontend
    +        if (!apiKey || !azure_base_url || !azure_deployment_name || !azure_api_version) {
    +            console.warn('Azure OpenAI status check failed: missing config from frontend.');
    +            return statusResponse.status(400).send({ error: true, message: 'Azure configuration is incomplete.' });
    +        }
    +        // 2) Build URLs using the URL API for consistency and robustness.
    +        const modelsUrl = new URL('/openai/models', azure_base_url);
    +        modelsUrl.searchParams.set('api-version', azure_api_version);
    +
    +        const chatUrl = new URL(`/openai/deployments/${azure_deployment_name}/chat/completions`, azure_base_url);
    +        chatUrl.searchParams.set('api-version', azure_api_version);
    +
    +        // Map common status codes to user-friendly error messages
    +        const azureStatusErrorMap = {
    +            400: 'API version may be invalid for this resource.',
    +            401: 'Invalid API key or insufficient permissions.',
    +            403: 'Invalid API key or insufficient permissions.',
    +            404: 'Endpoint URL appears incorrect (404).',
    +        };
    +
    +        try {
    +            // ---- A) GET /models: fast sanity check for endpoint + api key + api version ----
    +            const apiConfigTest = await fetch(modelsUrl, {
    +                method: 'GET',
    +                headers: { 'api-key': apiKey, 'Accept': 'application/json' },
    +            });
    +
    +            if (!apiConfigTest.ok) {
    +                let errText = '';
    +                try { errText = await apiConfigTest.text(); } catch { /* response body may be empty */ }
    +
    +                console.warn('Azure OpenAI GET /models failed:', apiConfigTest.status, apiConfigTest.statusText, errText || '');
    +
    +                const defaultMessage = `Azure Models endpoint error: ${apiConfigTest.statusText}`;
    +                const message = azureStatusErrorMap[apiConfigTest.status] ?? defaultMessage;
    +                return statusResponse.status(apiConfigTest.status).send({ error: true, message });
    +            }
    +
    +            // ---- B) POST /chat/completions: verify deployment + read underlying model ID ----
    +            // Small, deterministic probe to minimize cost/latency
    +            const modelPayload = {
    +                messages: [{ role: 'user', content: 'Say word Hi' }],
    +                stream: false,
    +                max_completion_tokens: 5,
    +            };
    +
    +            const modelRequest = await fetch(chatUrl, {
    +                method: 'POST',
    +                headers: { 'api-key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' },
    +                body: JSON.stringify(modelPayload),
    +            });
    +
    +            let modelResponse;
    +            try {
    +                modelResponse = await modelRequest.json();
    +            } catch {
    +                modelResponse = { raw: 'Failed to parse JSON response from chat completions probe.' };
    +            }
    +
    +            const modelId = /** @type {any} */ (modelResponse)?.model;
    +            if (!modelId) {
    +                console.warn('Azure status check succeeded but could not find a model ID in the response.');
    +                console.debug('Azure Response Body:', modelResponse);
    +                // Keep a benign success to avoid UX disruption in the UI
    +                return statusResponse.send({ data: [] });
    +            }
    +
    +            console.info(color.green('Azure OpenAI connection successful. Detected model:'), modelId);
    +            // Consistent response format: always an array of { id }
    +            return statusResponse.send({ data: [{ id: modelId }] });
    +        } catch (error) {
    +            console.error('Azure OpenAI status check connection error:', error);
    +            return statusResponse.status(500).send({ error: true, message: 'Failed to connect to the Azure endpoint.' });
    +        }
         } else {
             console.warn('This chat completion source is not supported yet.');
             return statusResponse.status(400).send({ error: true });
    @@ -1309,7 +1590,11 @@ router.post('/status', async function (request, statusResponse) {
         }
     
         try {
    -        const response = await fetch(apiUrl + '/models', {
    +        const modelsUrl = new URL(urlJoin(apiUrl, '/models'));
    +        Object.keys(queryParams).forEach(key => {
    +            modelsUrl.searchParams.append(key, queryParams[key]);
    +        });
    +        const response = await fetch(modelsUrl, {
                 method: 'GET',
                 headers: {
                     'Authorization': 'Bearer ' + apiKey,
    @@ -1485,6 +1770,8 @@ router.post('/generate', function (request, response) {
             case CHAT_COMPLETION_SOURCES.DEEPSEEK: return sendDeepSeekRequest(request, response);
             case CHAT_COMPLETION_SOURCES.AIMLAPI: return sendAimlapiRequest(request, response);
             case CHAT_COMPLETION_SOURCES.XAI: return sendXaiRequest(request, response);
    +        case CHAT_COMPLETION_SOURCES.ELECTRONHUB: return sendElectronHubRequest(request, response);
    +        case CHAT_COMPLETION_SOURCES.AZURE_OPENAI: return sendAzureOpenAIRequest(request, response);
         }
     
         let apiUrl;
    @@ -1644,7 +1931,17 @@ router.post('/generate', function (request, response) {
             if (request.body.enable_web_search && !/:online$/.test(request.body.model)) {
                 request.body.model = `${request.body.model}:online`;
             }
    -    } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.POLLINATIONS) {
    +        const enableSystemPromptCache = getConfigValue('claude.enableSystemPromptCache', false, 'boolean');
    +        const isClaude3or4 = /claude-(3|opus-4|sonnet-4)/.test(request.body.model);
    +        const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';
    +        if (enableSystemPromptCache && isClaude3or4) {
    +            bodyParams['cache_control'] = {
    +                'enabled': true,
    +                'ttl': cacheTTL,
    +            };
    +        }
    +    }
    +    else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.POLLINATIONS) {
             apiUrl = API_POLLINATIONS;
             apiKey = 'NONE';
             headers = {
    @@ -1682,26 +1979,8 @@ router.post('/generate', function (request, response) {
     
         // A few of OpenAIs reasoning models support reasoning effort
         if (request.body.reasoning_effort && [CHAT_COMPLETION_SOURCES.CUSTOM, CHAT_COMPLETION_SOURCES.OPENAI].includes(request.body.chat_completion_source)) {
    -        const reasoningEffortModels = [
    -            'o1',
    -            'o3-mini',
    -            'o3-mini-2025-01-31',
    -            'o4-mini',
    -            'o4-mini-2025-04-16',
    -            'o3',
    -            'o3-2025-04-16',
    -            'gpt-5',
    -            'gpt-5-2025-08-07',
    -            'gpt-5-mini',
    -            'gpt-5-mini-2025-08-07',
    -            'gpt-5-nano',
    -            'gpt-5-nano-2025-08-07',
    -        ];
    -        const reasoningEffortMap = {
    -            min: 'minimal',
    -        };
    -        if (reasoningEffortModels.includes(request.body.model)) {
    -            bodyParams['reasoning_effort'] = reasoningEffortMap[request.body.reasoning_effort] ?? request.body.reasoning_effort;
    +        if (OPENAI_REASONING_EFFORT_MODELS.includes(request.body.model)) {
    +            bodyParams['reasoning_effort'] = OPENAI_REASONING_EFFORT_MAP[request.body.reasoning_effort] ?? request.body.reasoning_effort;
             }
         }
     
    @@ -1777,7 +2056,7 @@ router.post('/generate', function (request, response) {
             signal: controller.signal,
         };
     
    -    console.debug(requestBody);
    +    console.debug('Chat Completion request:', requestBody);
     
         makeRequest(config, response, request);
     
    @@ -1786,10 +2065,8 @@ router.post('/generate', function (request, response) {
          * @param {import('node-fetch').RequestInit} config Fetch config
          * @param {express.Response} response Express response
          * @param {express.Request} request Express request
    -     * @param {Number} retries Number of retries left
    -     * @param {Number} timeout Request timeout in ms
          */
    -    async function makeRequest(config, response, request, retries = 5, timeout = 5000) {
    +    async function makeRequest(config, response, request) {
             try {
                 controller.signal.throwIfAborted();
                 const fetchResponse = await fetch(endpointUrl, config);
    @@ -1804,14 +2081,7 @@ router.post('/generate', function (request, response) {
                     /** @type {any} */
                     let json = await fetchResponse.json();
                     response.send(json);
    -                console.debug(json);
    -                console.debug(json?.choices?.[0]?.message);
    -            } else if (fetchResponse.status === 429 && retries > 0) {
    -                console.warn(`Out of quota, retrying in ${Math.round(timeout / 1000)}s`);
    -                setTimeout(() => {
    -                    timeout *= 2;
    -                    makeRequest(config, response, request, retries - 1, timeout);
    -                }, timeout);
    +                console.debug('Chat Completion response:', json);
                 } else {
                     await handleErrorResponse(fetchResponse);
                 }
    @@ -1843,16 +2113,16 @@ router.post('/generate', function (request, response) {
             if (!response.headersSent) {
                 response.send({ error: { message }, quota_error: quota_error });
             } else if (!response.writableEnded) {
    -            response.write(errorResponse);
    +            response.write(responseText);
             } else {
                 response.end();
             }
         }
     });
     
    -const pollinations = express.Router();
    +const multimodalModels = express.Router();
     
    -pollinations.post('/models/multimodal', async (_req, res) => {
    +multimodalModels.post('/pollinations', async (_req, res) => {
         try {
             const response = await fetch('https://text.pollinations.ai/models');
     
    @@ -1875,11 +2145,7 @@ pollinations.post('/models/multimodal', async (_req, res) => {
         }
     });
     
    -router.use('/pollinations', pollinations);
    -
    -const aimlapi = express.Router();
    -
    -aimlapi.post('/models/multimodal', async (_req, res) => {
    +multimodalModels.post('/aimlapi', async (_req, res) => {
         try {
             const response = await fetch('https://api.aimlapi.com/v1/models');
     
    @@ -1902,4 +2168,45 @@ aimlapi.post('/models/multimodal', async (_req, res) => {
         }
     });
     
    -router.use('/aimlapi', aimlapi);
    +multimodalModels.post('/nanogpt', async (_req, res) => {
    +    try {
    +        const response = await fetch('https://nano-gpt.com/api/v1/models?detailed=true');
    +
    +        if (!response.ok) {
    +            return res.json([]);
    +        }
    +
    +        /** @type {any} */
    +        const data = await response.json();
    +
    +        if (!Array.isArray(data?.data)) {
    +            return res.json([]);
    +        }
    +
    +        const multimodalModels = data.data.filter(m => m?.capabilities?.vision).map(m => m.id);
    +        return res.json(multimodalModels);
    +    } catch (error) {
    +        console.error(error);
    +        return res.sendStatus(500);
    +    }
    +});
    +
    +multimodalModels.post('/electronhub', async (_req, res) => {
    +    try {
    +        const response = await fetch('https://api.electronhub.ai/v1/models');
    +
    +        if (!response.ok) {
    +            return res.json([]);
    +        }
    +
    +        /** @type {any} */
    +        const data = await response.json();
    +        const multimodalModels = data.data.filter(m => m.metadata?.vision).map(m => m.id);
    +        return res.json(multimodalModels);
    +    } catch (error) {
    +        console.error(error);
    +        return res.sendStatus(500);
    +    }
    +});
    +
    +router.use('/multimodal-models', multimodalModels);
    
  • src/endpoints/backends/text-completions.js+2 1 modified
    @@ -478,7 +478,8 @@ ollama.post('/caption-image', async function (request, response) {
             });
     
             if (!fetchResponse.ok) {
    -            console.error('Ollama caption error:', fetchResponse.status, fetchResponse.statusText);
    +            const errorText = await fetchResponse.text();
    +            console.error('Ollama caption error:', fetchResponse.status, fetchResponse.statusText, errorText);
                 return response.status(500).send({ error: true });
             }
     
    
  • src/endpoints/content-manager.js+4 1 modified
    @@ -544,10 +544,13 @@ async function downloadJannyCharacter(uuid) {
                 const fileType = imageResult.headers.get('content-type');
     
                 return { buffer, fileName, fileType };
    +        } else {
    +            console.error('Janny failed to download', downloadResult);
             }
    +    } else {
    +        console.error('Janny returned error', result.statusText, await result.text());
         }
     
    -    console.error('Janny returned error', result.statusText, await result.text());
         throw new Error('Failed to download character');
     }
     
    
  • src/endpoints/openai.js+16 0 modified
    @@ -77,6 +77,14 @@ router.post('/caption-image', async (request, response) => {
                 key = readSecret(request.user.directories, SECRET_KEYS.MOONSHOT);
             }
     
    +        if (request.body.api === 'nanogpt') {
    +            key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
    +        }
    +
    +        if (request.body.api === 'electronhub') {
    +            key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
    +        }
    +
             const noKeyTypes = ['custom', 'ooba', 'koboldcpp', 'vllm', 'llamacpp', 'pollinations'];
             if (!key && !request.body.reverse_proxy && !noKeyTypes.includes(request.body.api)) {
                 console.warn('No key found for API', request.body.api);
    @@ -161,6 +169,14 @@ router.post('/caption-image', async (request, response) => {
                 apiUrl = 'https://api.moonshot.ai/v1/chat/completions';
             }
     
    +        if (request.body.api === 'nanogpt') {
    +            apiUrl = 'https://nano-gpt.com/api/v1/chat/completions';
    +        }
    +
    +        if (request.body.api === 'electronhub') {
    +            apiUrl = 'https://api.electronhub.ai/v1/chat/completions';
    +        }
    +
             if (['koboldcpp', 'vllm', 'llamacpp', 'ooba'].includes(request.body.api)) {
                 apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
             }
    
  • src/endpoints/openrouter.js+1 1 modified
    @@ -47,7 +47,7 @@ router.post('/models/multimodal', async (_req, res) => {
             /** @type {any} */
             const data = await response.json();
             const models = data?.data || [];
    -        const multimodalModels = models.filter(m => m?.architecture?.modality === 'text+image->text').map(m => m.id);
    +        const multimodalModels = models.filter(m =>  ['text+image->text+image', 'text+image->text'].includes(m?.architecture?.modality)).map(m => m.id);
     
             return res.json(multimodalModels);
         } catch (error) {
    
  • src/endpoints/secrets.js+2 0 modified
    @@ -45,6 +45,7 @@ export const SECRET_KEYS = {
         STABILITY: 'api_key_stability',
         CUSTOM_OPENAI_TTS: 'api_key_custom_openai_tts',
         TAVILY: 'api_key_tavily',
    +    ELECTRONHUB: 'api_key_electronhub',
         NANOGPT: 'api_key_nanogpt',
         BFL: 'api_key_bfl',
         FALAI: 'api_key_falai',
    @@ -59,6 +60,7 @@ export const SECRET_KEYS = {
         MINIMAX_GROUP_ID: 'minimax_group_id',
         MOONSHOT: 'api_key_moonshot',
         COMETAPI: 'api_key_cometapi',
    +    AZURE_OPENAI: 'api_key_azure_openai',
     };
     
     /**
    
  • src/endpoints/stable-diffusion.js+113 0 modified
    @@ -957,6 +957,118 @@ huggingface.post('/generate', async (request, response) => {
         }
     });
     
    +const electronhub = express.Router();
    +
    +electronhub.post('/models', async (request, response) => {
    +    try {
    +        const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
    +
    +        if (!key) {
    +            console.warn('Electron Hub key not found.');
    +            return response.sendStatus(400);
    +        }
    +
    +        const modelsResponse = await fetch('https://api.electronhub.ai/v1/models', {
    +            method: 'GET',
    +            headers: {
    +                'Authorization': `Bearer ${key}`,
    +                'Content-Type': 'application/json',
    +            },
    +        });
    +
    +        if (!modelsResponse.ok) {
    +            console.warn('Electron Hub returned an error.');
    +            return response.sendStatus(500);
    +        }
    +
    +        /** @type {any} */
    +        const data = await modelsResponse.json();
    +        const models = data.data.filter(x => x.endpoints.includes('/v1/images/generations')).map(x => ({ value: x.id, text: x.name }));
    +        return response.send(models);
    +    } catch (error) {
    +        console.error(error);
    +        return response.sendStatus(500);
    +    }
    +});
    +
    +electronhub.post('/generate', async (request, response) => {
    +    try {
    +        const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
    +
    +        if (!key) {
    +            console.warn('Electron Hub key not found.');
    +            return response.sendStatus(400);
    +        }
    +
    +        let bodyParams = {
    +            model: request.body.model,
    +            prompt: request.body.prompt,
    +            response_format: 'b64_json',
    +        };
    +
    +        if (request.body.size) {
    +            bodyParams.size = request.body.size;
    +        }
    +
    +        const result = await fetch('https://api.electronhub.ai/v1/images/generations', {
    +            method: 'POST',
    +            headers: {
    +                'Authorization': `Bearer ${key}`,
    +                'Content-Type': 'application/json',
    +            },
    +            body: JSON.stringify({
    +                ...bodyParams,
    +            }),
    +        });
    +
    +        if (!result.ok) {
    +            const errorText = await result.text();
    +            console.warn('Electron Hub returned an error.', result.status, result.statusText, errorText);
    +            return response.sendStatus(500);
    +        }
    +
    +        /** @type {any} */
    +        const data = await result.json();
    +        const image = data?.data?.[0]?.b64_json;
    +
    +        if (!image) {
    +            console.warn('Electron Hub returned invalid data.');
    +            return response.sendStatus(500);
    +        }
    +
    +        return response.send({ image });
    +    } catch (error) {
    +        console.error(error);
    +        return response.sendStatus(500);
    +    }
    +});
    +
    +electronhub.post('/sizes', async (request, response) => {
    +    const result = await fetch(`https://api.electronhub.ai/v1/models/${request.body.model}`, {
    +        method: 'GET',
    +        headers: {
    +            'Content-Type': 'application/json',
    +        },
    +    });
    +
    +    if (!result.ok) {
    +        console.warn('Electron Hub returned an error.');
    +        return response.sendStatus(500);
    +    }
    +
    +    /** @type {any} */
    +    const data = await result.json();
    +
    +    const sizes = data.sizes;
    +
    +    if (!sizes) {
    +        console.warn('Electron Hub returned invalid data.');
    +        return response.sendStatus(500);
    +    }
    +
    +    return response.send({ sizes });
    +});
    +
     const nanogpt = express.Router();
     
     nanogpt.post('/models', async (request, response) => {
    @@ -1439,6 +1551,7 @@ router.use('/drawthings', drawthings);
     router.use('/pollinations', pollinations);
     router.use('/stability', stability);
     router.use('/huggingface', huggingface);
    +router.use('/electronhub', electronhub);
     router.use('/nanogpt', nanogpt);
     router.use('/bfl', bfl);
     router.use('/falai', falai);
    
  • src/middleware/hostWhitelist.js+48 0 added
    @@ -0,0 +1,48 @@
    +import path from 'node:path';
    +import { color, getConfigValue, safeReadFileSync } from '../util.js';
    +import { serverDirectory } from '../server-directory.js';
    +import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware';
    +
    +const knownHosts = new Set();
    +const maxKnownHosts = 1000;
    +
    +const hostWhitelistEnabled = !!getConfigValue('hostWhitelist.enabled', false);
    +const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', []));
    +const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean');
    +
    +const hostNotAllowedHtml = safeReadFileSync(path.join(serverDirectory, 'public/error/host-not-allowed.html'))?.toString() ?? '';
    +
    +const validationMiddleware = hostValidationMiddleware({
    +    allowedHosts: hostWhitelist,
    +    generateErrorMessage: () => hostNotAllowedHtml,
    +    errorResponseContentType: 'text/html',
    +});
    +
    +/**
    + * Middleware to validate remote hosts.
    + * Useful to protect against DNS rebinding attacks.
    + * @param {import('express').Request} req Request
    + * @param {import('express').Response} res Response
    + * @param {import('express').NextFunction} next Next middleware
    + */
    +export default function hostWhitelistMiddleware(req, res, next) {
    +    const hostValue = req.headers.host;
    +    if (hostWhitelistScan && !isHostAllowed(hostValue, hostWhitelist) && !knownHosts.has(hostValue) && knownHosts.size < maxKnownHosts) {
    +        const isFirstWarning = knownHosts.size === 0;
    +        console.warn(color.red('Request from untrusted host:'), hostValue);
    +        console.warn(`If you trust this host, you can add it to ${color.yellow('hostWhitelist.hosts')} in config.yaml`);
    +        if (!hostWhitelistEnabled && isFirstWarning) {
    +            console.warn(`To protect against host spoofing, consider setting ${color.yellow('hostWhitelist.enabled')} to true`);
    +        }
    +        if (isFirstWarning) {
    +            console.warn(`To disable this warning, set ${color.yellow('hostWhitelist.scan')} to false`);
    +        }
    +        knownHosts.add(hostValue);
    +    }
    +
    +    if (!hostWhitelistEnabled) {
    +        return next();
    +    }
    +
    +    return validationMiddleware(req, res, next);
    +}
    
  • src/server-main.js+3 0 modified
    @@ -46,6 +46,7 @@ import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
     import initRequestProxy from './request-proxy.js';
     import cacheBuster from './middleware/cacheBuster.js';
     import corsProxyMiddleware from './middleware/corsProxy.js';
    +import hostWhitelistMiddleware from './middleware/hostWhitelist.js';
     import {
         getVersion,
         color,
    @@ -116,6 +117,8 @@ if (cliArgs.whitelistMode) {
         app.use(whitelistMiddleware);
     }
     
    +app.use(hostWhitelistMiddleware);
    +
     if (cliArgs.listen) {
         app.use(accessLoggerMiddleware());
     }
    
  • src/server-startup.js+2 0 modified
    @@ -233,9 +233,11 @@ export class ServerStartup {
         #createHttpsServer(url, ipVersion) {
             this.#verifySslOptions();
             return new Promise((resolve, reject) => {
    +            /** @type {import('https').ServerOptions} */
                 const sslOptions = {
                     cert: fs.readFileSync(this.cliArgs.certPath),
                     key: fs.readFileSync(this.cliArgs.keyPath),
    +                passphrase: String(this.cliArgs.keyPassphrase ?? ''),
                 };
                 const server = https.createServer(sslOptions, this.app);
                 server.on('error', reject);
    
d134abd50e4a

Server: Add host whitelisting (#4476)

6 files changed · +95 0
  • default/config.yaml+12 0 modified
    @@ -94,6 +94,18 @@ autheliaAuth: false
     # the username and passwords for basic auth are the same as those
     # for the individual accounts
     perUserBasicAuth: false
    +# Host whitelist configuration. Recommended if you're using a listen mode
    +hostWhitelist:
    +  # Enable or disable host whitelisting
    +  enabled: false
    +  # Scan incoming requests for potential host header spoofing
    +  scan: true
    +  # List of allowed hosts. Do not include localhost or IPs, these are safe.
    +  # Use a dot to create subdomain patterns.
    +  # Examples:
    +  # - example.com
    +  # - .trycloudflare.com
    +  hosts: []
     
     # User session timeout *in seconds* (defaults to 24 hours).
     ## Set to a positive number to expire session after a certain time of inactivity
    
  • default/public/error/host-not-allowed.html+21 0 added
    @@ -0,0 +1,21 @@
    +<!DOCTYPE html>
    +<html>
    +
    +<head>
    +    <title>Forbidden</title>
    +</head>
    +
    +<body>
    +    <h1>Forbidden</h1>
    +    <p>
    +        If you are the system administrator, add the hostname you are accessing from to the
    +        host whitelist, or disable host whitelisting in the
    +        <code>config.yaml</code> file located in the root directory of your installation.
    +    </p>
    +    <hr />
    +    <p>
    +        <em>Access from this host is not allowed. This attempt has been logged.</em>
    +    </p>
    +</body>
    +
    +</html>
    
  • package.json+1 0 modified
    @@ -54,6 +54,7 @@
             "handlebars": "^4.7.8",
             "helmet": "^8.1.0",
             "highlight.js": "^11.11.1",
    +        "host-validation-middleware": "^0.1.1",
             "html-entities": "^2.6.0",
             "iconv-lite": "^0.6.3",
             "ip-matching": "^2.1.2",
    
  • package-lock.json+10 0 modified
    @@ -64,6 +64,7 @@
                     "handlebars": "^4.7.8",
                     "helmet": "^8.1.0",
                     "highlight.js": "^11.11.1",
    +                "host-validation-middleware": "^0.1.1",
                     "html-entities": "^2.6.0",
                     "iconv-lite": "^0.6.3",
                     "ip-matching": "^2.1.2",
    @@ -5249,6 +5250,15 @@
                     "node": ">=12.0.0"
                 }
             },
    +        "node_modules/host-validation-middleware": {
    +            "version": "0.1.1",
    +            "resolved": "https://registry.npmjs.org/host-validation-middleware/-/host-validation-middleware-0.1.1.tgz",
    +            "integrity": "sha512-fakcpp+x4nbP0fACY5gaHWpaOfstq3w8uB6wvhbPBLqH9GV/tdiM9Ht5mclZVbUuPLGBw1bkH5yyTD6HZq057g==",
    +            "license": "MIT",
    +            "engines": {
    +                "node": "^18.0.0 || >=20.0.0"
    +            }
    +        },
             "node_modules/html-entities": {
                 "version": "2.6.0",
                 "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
    
  • src/middleware/hostWhitelist.js+48 0 added
    @@ -0,0 +1,48 @@
    +import path from 'node:path';
    +import { color, getConfigValue, safeReadFileSync } from '../util.js';
    +import { serverDirectory } from '../server-directory.js';
    +import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware';
    +
    +const knownHosts = new Set();
    +const maxKnownHosts = 1000;
    +
    +const hostWhitelistEnabled = !!getConfigValue('hostWhitelist.enabled', false);
    +const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', []));
    +const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean');
    +
    +const hostNotAllowedHtml = safeReadFileSync(path.join(serverDirectory, 'public/error/host-not-allowed.html'))?.toString() ?? '';
    +
    +const validationMiddleware = hostValidationMiddleware({
    +    allowedHosts: hostWhitelist,
    +    generateErrorMessage: () => hostNotAllowedHtml,
    +    errorResponseContentType: 'text/html',
    +});
    +
    +/**
    + * Middleware to validate remote hosts.
    + * Useful to protect against DNS rebinding attacks.
    + * @param {import('express').Request} req Request
    + * @param {import('express').Response} res Response
    + * @param {import('express').NextFunction} next Next middleware
    + */
    +export default function hostWhitelistMiddleware(req, res, next) {
    +    const hostValue = req.headers.host;
    +    if (hostWhitelistScan && !isHostAllowed(hostValue, hostWhitelist) && !knownHosts.has(hostValue) && knownHosts.size < maxKnownHosts) {
    +        const isFirstWarning = knownHosts.size === 0;
    +        console.warn(color.red('Request from untrusted host:'), hostValue);
    +        console.warn(`If you trust this host, you can add it to ${color.yellow('hostWhitelist.hosts')} in config.yaml`);
    +        if (!hostWhitelistEnabled && isFirstWarning) {
    +            console.warn(`To protect against host spoofing, consider setting ${color.yellow('hostWhitelist.enabled')} to true`);
    +        }
    +        if (isFirstWarning) {
    +            console.warn(`To disable this warning, set ${color.yellow('hostWhitelist.scan')} to false`);
    +        }
    +        knownHosts.add(hostValue);
    +    }
    +
    +    if (!hostWhitelistEnabled) {
    +        return next();
    +    }
    +
    +    return validationMiddleware(req, res, next);
    +}
    
  • src/server-main.js+3 0 modified
    @@ -46,6 +46,7 @@ import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
     import initRequestProxy from './request-proxy.js';
     import cacheBuster from './middleware/cacheBuster.js';
     import corsProxyMiddleware from './middleware/corsProxy.js';
    +import hostWhitelistMiddleware from './middleware/hostWhitelist.js';
     import {
         getVersion,
         color,
    @@ -116,6 +117,8 @@ if (cliArgs.whitelistMode) {
         app.use(whitelistMiddleware);
     }
     
    +app.use(hostWhitelistMiddleware);
    +
     if (cliArgs.listen) {
         app.use(accessLoggerMiddleware());
     }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.