diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 966c01129..e548f0d64 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 with: node-version: 24 - - run: npm ci + - run: npm ci --omit=dev --ignore-scripts publish-npm: needs: build @@ -30,5 +30,5 @@ jobs: with: node-version: 24 registry-url: https://registry.npmjs.org/ - - run: npm ci + - run: npm ci --omit=dev --ignore-scripts - run: npm publish diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..2143f3df2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +min-release-age=7 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..46aaa6638 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "js/ts.tsdk.path": "./node_modules/typescript/lib" +} diff --git a/Dockerfile b/Dockerfile index 999164f40..31dab85c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ COPY --chown=node:node . ./ RUN \ echo "*** Install npm packages ***" && \ - npm ci --no-audit --no-fund --loglevel=error --no-progress --omit=dev && npm cache clean --force + npm ci --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts && npm cache clean --force # Create config directory and link config.yaml. Added hardcoded dirs(constants.js?) # that must be present for Non-Root Mode and volumeless docker runs. diff --git a/Start.bat b/Start.bat index 9be1a133e..f9acf52ae 100644 --- a/Start.bat +++ b/Start.bat @@ -1,7 +1,7 @@ @echo off pushd %~dp0 set NODE_ENV=production -call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev +call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts node server.js %* pause popd diff --git a/UpdateAndStart.bat b/UpdateAndStart.bat index 766d52e5a..b142a1405 100644 --- a/UpdateAndStart.bat +++ b/UpdateAndStart.bat @@ -20,7 +20,7 @@ if %errorlevel% neq 0 ( ) ) set NODE_ENV=production -call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev +call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts node server.js %* :end pause diff --git a/UpdateForkAndStart.bat b/UpdateForkAndStart.bat index 68a9885a6..e02fd13d5 100644 --- a/UpdateForkAndStart.bat +++ b/UpdateForkAndStart.bat @@ -102,7 +102,7 @@ if %errorlevel% neq 0 ( echo Installing npm packages and starting server set NODE_ENV=production -call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev +call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts node server.js %* :end diff --git a/default/config.yaml b/default/config.yaml index 817aebcd8..e61f783ff 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -41,6 +41,9 @@ port: 8000 # Interval in seconds to write a heartbeat file. Set to 0 to disable. # This is used primarily for Docker healthchecks. heartbeatInterval: 0 +# Enable HTTP/HTTPS keep-alive globally. +# Disabling restores old Node 18 behavior, can help if ECONNRESET and other network errors occur. +enableKeepAlive: false # -- SSL options -- ssl: # Enable SSL/TLS encryption @@ -55,7 +58,7 @@ ssl: # -- SECURITY CONFIGURATION -- # Toggle whitelist mode whitelistMode: true -# Whitelist will also verify IP in X-Forwarded-For / X-Real-IP headers +# When enabled, whitelist will also verify IP in headers enabled in `forwardedHeaders` section. enableForwardedWhitelist: true # Whitelist of allowed IP addresses whitelist: @@ -127,6 +130,13 @@ sso: # as that used for authentik. (Ensure the username in authentik # is an exact match in lowercase with that in sillytavern). authentikAuth: false + # List of trusted proxy IPs for SSO authentication. + # Supports wildcards or CIDR notation for subnets. + # Example: ['127.0.0.1', '192.168.1.1'] + # Set to ['*'] to trust all proxies (NOT RECOMMENDED unless you have other security measures in place) + trustedProxies: + - ::1 + - 127.0.0.1 # Host whitelist configuration. Recommended if you're using a listen mode hostWhitelist: @@ -141,6 +151,26 @@ hostWhitelist: # - .trycloudflare.com hosts: [] +# Perform whitelist checks against server-side HTTP requests that resolve to private IP addresses. +# This is an additional layer of security to prevent Server-Side Request Forgery (SSRF) attacks. +# Recommended when listen mode is enabled, or if your server is accessible by untrusted users. +privateAddressWhitelist: + # Enable private address whitelist to block requests to private IP ranges. + enabled: false + # If true, requests to hosts that cannot be resolved will be allowed instead of blocked. + allowUnresolvedHosts: false + # Log blocked and allowed requests to the console. + log: + # Log blocked requests to the console with a warning message + blockedRequests: true + # Log allowed requests to the console with an info message + allowedRequests: false + # List of allowed private IP ranges (in CIDR notation or wildcard format). + # Allows loopback IP ranges by default, but you can customize this list to fit your needs. + allowedRanges: + - '127.0.0.0/8' # Loopback (IPv4) + - '::1/128' # Loopback (IPv6) + # User session timeout *in seconds* (defaults to 24 hours). ## Set to a positive number to expire session after a certain time of inactivity ## Set to 0 to expire session when the browser is closed @@ -159,9 +189,24 @@ logging: minLogLevel: 0 # -- RATE LIMITING CONFIGURATION -- rateLimiting: - # Use X-Real-IP header instead of socket IP for rate limiting - # Only enable this if you are using a properly configured reverse proxy (like Nginx/traefik/Caddy) + # Use any of the enabled headers in the `forwardedHeaders` section to identify the client IP for rate limiting. + # If disabled, only the socket IP will be used, which may not work correctly if you are behind a reverse proxy. preferRealIpHeader: false + # Set the maximum number of allowed failed basic authentication attempts before rate limiting is applied. Set to 0 to disable rate limiting for basic auth. + basicAuthMaxAttempts: 5 + # Set the maximum number of allowed failed account login attempts before rate limiting is applied. Set to 0 to disable rate limiting for account logins. + accountsLoginMaxAttempts: 5 + # Set the maximum number of allowed failed account recovery attempts before rate limiting is applied. Set to 0 to disable rate limiting for account recovery. + accountsRecoverMaxAttempts: 5 +# Set to true to enable support for real IPs in certain request headers for features like IP whitelisting, rate limiting and access logging. +# Only change if you are sure that you use a correctly configured reverse proxy, otherwise this may lead to IP spoofing. +forwardedHeaders: + # X-Real-IP header (common with Nginx and Caddy) + xRealIp: true + # X-Forwarded-For header (common with many proxies, but may contain multiple IPs - only the first one will be used) + xForwardedFor: true + # CF-Connecting-IP header (used by Cloudflare Tunnels) + cfConnectingIp: false ## BACKUP CONFIGURATION backups: diff --git a/default/public/error/forbidden-by-whitelist.html b/default/content/errors/forbidden-by-whitelist.html similarity index 100% rename from default/public/error/forbidden-by-whitelist.html rename to default/content/errors/forbidden-by-whitelist.html diff --git a/default/public/error/host-not-allowed.html b/default/content/errors/host-not-allowed.html similarity index 100% rename from default/public/error/host-not-allowed.html rename to default/content/errors/host-not-allowed.html diff --git a/default/public/error/unauthorized.html b/default/content/errors/unauthorized.html similarity index 100% rename from default/public/error/unauthorized.html rename to default/content/errors/unauthorized.html diff --git a/default/public/error/url-not-found.html b/default/content/errors/url-not-found.html similarity index 72% rename from default/public/error/url-not-found.html rename to default/content/errors/url-not-found.html index 87974145f..9c881dfc2 100644 --- a/default/public/error/url-not-found.html +++ b/default/content/errors/url-not-found.html @@ -2,11 +2,11 @@ - Not found + Not Found -

Not found

+

Not Found

The requested URL was not found on this server.

diff --git a/default/content/index.json b/default/content/index.json index 69018613e..39191f07a 100644 --- a/default/content/index.json +++ b/default/content/index.json @@ -642,5 +642,37 @@ { "filename": "presets/reasoning/Think XML.json", "type": "reasoning" + }, + { + "filename": "presets/reasoning/Gemma 4.json", + "type": "reasoning" + }, + { + "filename": "presets/instruct/Gemma 4.json", + "type": "instruct" + }, + { + "filename": "presets/context/Gemma 4.json", + "type": "context" + }, + { + "filename": "user.css", + "type": "stylesheet" + }, + { + "filename": "errors/forbidden-by-whitelist.html", + "type": "error_page" + }, + { + "filename": "errors/host-not-allowed.html", + "type": "error_page" + }, + { + "filename": "errors/unauthorized.html", + "type": "error_page" + }, + { + "filename": "errors/url-not-found.html", + "type": "error_page" } ] diff --git a/default/content/presets/context/Gemma 4.json b/default/content/presets/context/Gemma 4.json new file mode 100644 index 000000000..8bc758ff0 --- /dev/null +++ b/default/content/presets/context/Gemma 4.json @@ -0,0 +1,14 @@ +{ + "story_string": "{{#if anchorBefore}}{{anchorBefore}}\n{{/if}}{{#if system}}{{system}}\n{{/if}}{{#if wiBefore}}{{wiBefore}}\n{{/if}}{{#if description}}{{description}}\n{{/if}}{{#if personality}}{{personality}}\n{{/if}}{{#if scenario}}{{scenario}}\n{{/if}}{{#if wiAfter}}{{wiAfter}}\n{{/if}}{{#if persona}}{{persona}}\n{{/if}}{{#if anchorAfter}}{{anchorAfter}}\n{{/if}}{{trim}}", + "example_separator": "", + "chat_start": "", + "use_stop_strings": false, + "names_as_stop_strings": true, + "story_string_position": 0, + "story_string_depth": 1, + "story_string_role": 0, + "always_force_name2": true, + "trim_sentences": false, + "single_line": false, + "name": "Gemma 4" +} diff --git a/default/content/presets/instruct/Gemma 4.json b/default/content/presets/instruct/Gemma 4.json new file mode 100644 index 000000000..b4a9dde99 --- /dev/null +++ b/default/content/presets/instruct/Gemma 4.json @@ -0,0 +1,25 @@ +{ + "input_sequence": "<|turn>user\n", + "output_sequence": "<|turn>model\n", + "last_output_sequence": "", + "system_sequence": "<|turn>system\n", + "stop_sequence": "", + "wrap": false, + "macro": true, + "names_behavior": "force", + "activation_regex": "", + "first_output_sequence": "", + "skip_examples": false, + "output_suffix": "\n", + "input_suffix": "\n", + "system_suffix": "\n", + "user_alignment_message": "", + "system_same_as_user": false, + "last_system_sequence": "", + "first_input_sequence": "", + "last_input_sequence": "", + "sequences_as_stop_strings": true, + "story_string_prefix": "<|turn>system\n", + "story_string_suffix": "\n", + "name": "Gemma 4" +} diff --git a/default/content/presets/openai/Default.json b/default/content/presets/openai/Default.json index 24115b9c1..db422d1bf 100644 --- a/default/content/presets/openai/Default.json +++ b/default/content/presets/openai/Default.json @@ -10,6 +10,8 @@ "mistralai_model": "mistral-large-latest", "chutes_model": "deepseek-ai/DeepSeek-V3-0324", "chutes_sort_models": "alphabetically", + "minimax_model": "MiniMax-M2.7", + "minimax_endpoint": "global", "electronhub_model": "gpt-4o-mini", "electronhub_sort_models": "alphabetically", "electronhub_group_models": false, diff --git a/default/content/presets/reasoning/Gemma 4.json b/default/content/presets/reasoning/Gemma 4.json new file mode 100644 index 000000000..012cfa003 --- /dev/null +++ b/default/content/presets/reasoning/Gemma 4.json @@ -0,0 +1,6 @@ +{ + "name": "Gemma 4", + "prefix": "<|channel>thought\n", + "suffix": "", + "separator": "\n\n" +} diff --git a/default/public/css/user.css b/default/content/user.css similarity index 100% rename from default/public/css/user.css rename to default/content/user.css diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index b4b744266..54e6d79b1 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/sh -# Function to handle startup logic (Config check + Postinstall + Start) +# Function to handle startup logic (Config check + init + Start) start_sillytavern() { local PREFIX="$1" shift # Remove the first argument (PREFIX) so $@ contains the rest @@ -11,8 +11,8 @@ start_sillytavern() { $PREFIX cp "default/config.yaml" "config/config.yaml" fi - # Execute postinstall to auto-populate config.yaml with missing values - $PREFIX npm run postinstall + # Execute init script to auto-populate config.yaml with missing values + $PREFIX npm run init # Start the server exec $PREFIX node server.js --listen "$@" diff --git a/index.d.ts b/index.d.ts index 2e55437c1..5bffcad3f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -41,6 +41,10 @@ declare global { * Authenticated user handle. */ handle: string | null; + /** + * Account version tag: shake256 derivative of password hash and salt. + */ + version: string | null; /** * Last time the session was extended. */ diff --git a/package-lock.json b/package-lock.json index 1f7b82749..95b4dbc33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,12 @@ { "name": "sillytavern", - "version": "1.17.0", + "version": "1.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sillytavern", - "version": "1.17.0", - "hasInstallScript": true, + "version": "1.18.0", "license": "AGPL-3.0", "dependencies": { "@adobe/css-tools": "^4.4.4", @@ -39,13 +38,14 @@ "@mozilla/readability": "^0.6.0", "@popperjs/core": "^2.11.8", "@zeldafan0225/ai_horde": "^5.2.0", + "agent-base": "^7.1.3", "archiver": "^7.0.1", "bing-translate-api": "^4.1.0", "body-parser": "^1.20.2", "bowser": "^2.12.1", "bytes": "^3.1.2", "chalk": "^5.6.0", - "chevrotain": "^11.1.1", + "chevrotain": "^11.2.0", "command-exists": "^1.2.9", "compression": "^1.8.1", "cookie-parser": "^1.4.6", @@ -54,7 +54,7 @@ "crc": "^4.3.2", "csrf-sync": "^4.2.1", "diff-match-patch": "^1.0.5", - "dompurify": "^3.2.6", + "dompurify": "^3.4.2", "droll": "^0.2.1", "env-paths": "^3.0.0", "express": "^4.21.0", @@ -74,8 +74,9 @@ "ipaddr.js": "^2.2.0", "is-docker": "^3.0.0", "isomorphic-git": "^1.36.3", + "js-sha256": "^0.11.1", "localforage": "^1.10.0", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "mime-types": "^3.0.2", "moment": "^2.30.1", "morphdom": "^2.7.7", @@ -109,7 +110,7 @@ "sillytavern": "src/server-global.js" }, "devDependencies": { - "@chevrotain/types": "^11.0.3", + "@chevrotain/types": "^11.2.0", "@types/archiver": "^6.0.3", "@types/bytes": "^3.1.5", "@types/command-exists": "^1.2.3", @@ -123,7 +124,7 @@ "@types/jquery-cropper": "^1.0.4", "@types/jquery.transit": "^0.9.33", "@types/jqueryui": "^1.12.24", - "@types/lodash": "^4.17.20", + "@types/lodash": "^4.17.24", "@types/mime-types": "^3.0.1", "@types/multer": "^2.1.0", "@types/node": "^18.19.84", @@ -139,7 +140,8 @@ "eslint": "^8.57.1", "eslint-plugin-jest": "^27.9.0", "eslint-plugin-jsdoc": "^48.10.0", - "eslint-plugin-playwright": "^2.3.0" + "eslint-plugin-playwright": "^2.3.0", + "typescript": "^5.9.3" }, "engines": { "node": ">= 20" @@ -177,42 +179,42 @@ "license": "Apache-2.0" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.1.tgz", - "integrity": "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.2.0.tgz", + "integrity": "sha512-ssJFvn/UXhQQeICw3SR/fZPmYVj+JM2mP+Lx7bZ51cOeHaMWOKp3AUMuyM3QR82aFFXTfcAp67P5GpPjGmbZWQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.1.1", - "@chevrotain/types": "11.1.1", + "@chevrotain/gast": "11.2.0", + "@chevrotain/types": "11.2.0", "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/gast": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.1.tgz", - "integrity": "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.2.0.tgz", + "integrity": "sha512-c+KoD6eSI1xjAZZoNUW+V0l13UEn+a4ShmUrjIKs1BeEWCji0Kwhmqn5FSx1K4BhWL7IQKlV7wLR4r8lLArORQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.1.1", + "@chevrotain/types": "11.2.0", "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.1.tgz", - "integrity": "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.2.0.tgz", + "integrity": "sha512-lG73pBFqbXODTbXhdZwv0oyUaI+3Irm+uOv5/W79lI3g5hasYaJnVJOm3H2NkhA0Ef4XLBU4Scr7TJDJwgFkAw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.1.tgz", - "integrity": "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.2.0.tgz", + "integrity": "sha512-vBMSj/lz/LqolbGQEHB0tlpW5BnljHVtp+kzjQfQU+5BtGMTuZCPVgaAjtKvQYXnHb/8i/02Kii00y0tsuwfsw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.1.tgz", - "integrity": "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.2.0.tgz", + "integrity": "sha512-+7whECg4yNWHottjvr2To2BRxL4XJVjIyyv5J4+bJ0iMOVU8j/8n1qPDLZS/90W/BObDR8VNL46lFbzY/Hosmw==", "license": "Apache-2.0" }, "node_modules/@es-joy/jsdoccomment": { @@ -549,7 +551,6 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -1011,7 +1012,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", @@ -1043,7 +1043,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1139,7 +1138,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1176,7 +1174,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -1220,7 +1217,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1308,7 +1304,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1321,7 +1316,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -1922,7 +1916,6 @@ "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2018,9 +2011,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "dev": true, "license": "MIT" }, @@ -2612,7 +2605,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2799,9 +2791,9 @@ } }, "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3021,14 +3013,23 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" } }, "node_modules/b4a": { @@ -3083,9 +3084,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3190,9 +3191,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3232,7 +3233,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3503,16 +3503,16 @@ } }, "node_modules/chevrotain": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz", - "integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.2.0.tgz", + "integrity": "sha512-mHCHTxM51nCklUw9RzRVc0DLjAh/SAUPM4k/zMInlTIo25ldWXOZoPt7XEIk/LwoT4lFVmJcu9g5MHtx371x3A==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.1.1", - "@chevrotain/gast": "11.1.1", - "@chevrotain/regexp-to-ast": "11.1.1", - "@chevrotain/types": "11.1.1", - "@chevrotain/utils": "11.1.1", + "@chevrotain/cst-dts-gen": "11.2.0", + "@chevrotain/gast": "11.2.0", + "@chevrotain/regexp-to-ast": "11.2.0", + "@chevrotain/types": "11.2.0", + "@chevrotain/utils": "11.2.0", "lodash-es": "4.17.23" } }, @@ -4322,13 +4322,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -4567,7 +4564,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5209,9 +5205,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -6378,6 +6374,12 @@ "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "license": "BSD-3-Clause" }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -6614,9 +6616,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { @@ -7926,9 +7928,9 @@ } }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -8125,7 +8127,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9143,7 +9144,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 6e86a216d..12c30fc06 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,14 @@ "@mozilla/readability": "^0.6.0", "@popperjs/core": "^2.11.8", "@zeldafan0225/ai_horde": "^5.2.0", + "agent-base": "^7.1.3", "archiver": "^7.0.1", "bing-translate-api": "^4.1.0", "body-parser": "^1.20.2", "bowser": "^2.12.1", "bytes": "^3.1.2", "chalk": "^5.6.0", - "chevrotain": "^11.1.1", + "chevrotain": "^11.2.0", "command-exists": "^1.2.9", "compression": "^1.8.1", "cookie-parser": "^1.4.6", @@ -44,7 +45,7 @@ "crc": "^4.3.2", "csrf-sync": "^4.2.1", "diff-match-patch": "^1.0.5", - "dompurify": "^3.2.6", + "dompurify": "^3.4.2", "droll": "^0.2.1", "env-paths": "^3.0.0", "express": "^4.21.0", @@ -64,8 +65,9 @@ "ipaddr.js": "^2.2.0", "is-docker": "^3.0.0", "isomorphic-git": "^1.36.3", + "js-sha256": "^0.11.1", "localforage": "^1.10.0", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "mime-types": "^3.0.2", "moment": "^2.30.1", "morphdom": "^2.7.7", @@ -113,8 +115,9 @@ "type": "git", "url": "https://github.com/SillyTavern/SillyTavern.git" }, - "version": "1.17.0", + "version": "1.18.0", "scripts": { + "init": "node src/server-init.js", "start": "node server.js", "debug": "node --inspect server.js", "start:global": "node server.js --global", @@ -122,7 +125,6 @@ "start:deno": "deno run --allow-run --allow-net --allow-read --allow-write --allow-sys --allow-env server.js", "start:bun": "bun server.js", "start:no-csrf": "node server.js --disableCsrf", - "postinstall": "node post-install.js", "lint": "eslint \"src/**/*.js\" \"public/**/*.js\" ./*.js", "lint:fix": "eslint \"src/**/*.js\" \"public/**/*.js\" ./*.js --fix", "plugins:update": "node plugins update", @@ -137,7 +139,7 @@ }, "main": "server.js", "devDependencies": { - "@chevrotain/types": "^11.0.3", + "@chevrotain/types": "^11.2.0", "@types/archiver": "^6.0.3", "@types/bytes": "^3.1.5", "@types/command-exists": "^1.2.3", @@ -151,7 +153,7 @@ "@types/jquery-cropper": "^1.0.4", "@types/jquery.transit": "^0.9.33", "@types/jqueryui": "^1.12.24", - "@types/lodash": "^4.17.20", + "@types/lodash": "^4.17.24", "@types/mime-types": "^3.0.1", "@types/multer": "^2.1.0", "@types/node": "^18.19.84", @@ -167,6 +169,7 @@ "eslint": "^8.57.1", "eslint-plugin-jest": "^27.9.0", "eslint-plugin-jsdoc": "^48.10.0", - "eslint-plugin-playwright": "^2.3.0" + "eslint-plugin-playwright": "^2.3.0", + "typescript": "^5.9.3" } } diff --git a/post-install.js b/post-install.js deleted file mode 100644 index 4908e950e..000000000 --- a/post-install.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Scripts to be done before starting the server for the first time. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; -import yaml from 'yaml'; -import chalk from 'chalk'; -import { createRequire } from 'node:module'; -import { addMissingConfigValues } from './src/config-init.js'; - -/** - * Colorizes console output. - */ -const color = chalk; - -/** - * Converts the old config.conf file to the new config.yaml format. - */ -function convertConfig() { - if (fs.existsSync('./config.conf')) { - if (fs.existsSync('./config.yaml')) { - console.log(color.yellow('Both config.conf and config.yaml exist. Please delete config.conf manually.')); - return; - } - - try { - console.log(color.blue('Converting config.conf to config.yaml. Your old config.conf will be renamed to config.conf.bak')); - fs.renameSync('./config.conf', './config.conf.cjs'); // Force loading as CommonJS - const require = createRequire(import.meta.url); - const config = require(path.join(process.cwd(), './config.conf.cjs')); - fs.copyFileSync('./config.conf.cjs', './config.conf.bak'); - fs.rmSync('./config.conf.cjs'); - fs.writeFileSync('./config.yaml', yaml.stringify(config)); - console.log(color.green('Conversion successful. Please check your config.yaml and fix it if necessary.')); - } catch (error) { - console.error(color.red('FATAL: Config conversion failed. Please check your config.conf file and try again.'), error); - return; - } - } -} - -/** - * Creates the default config files if they don't exist yet. - */ -function createDefaultFiles() { - /** - * @typedef DefaultItem - * @type {object} - * @property {'file' | 'directory'} type - Whether the item should be copied as a single file or merged into a directory structure. - * @property {string} defaultPath - The path to the default item (typically in `default/`). - * @property {string} productionPath - The path to the copied item for production use. - */ - - /** @type {DefaultItem[]} */ - const defaultItems = [ - { - type: 'file', - defaultPath: './default/config.yaml', - productionPath: './config.yaml', - }, - { - type: 'directory', - defaultPath: './default/public/', - productionPath: './public/', - }, - ]; - - for (const defaultItem of defaultItems) { - try { - if (defaultItem.type === 'file') { - if (!fs.existsSync(defaultItem.productionPath)) { - fs.copyFileSync( - defaultItem.defaultPath, - defaultItem.productionPath, - ); - console.log( - color.green(`Created default file: ${defaultItem.productionPath}`), - ); - } - } else if (defaultItem.type === 'directory') { - fs.cpSync(defaultItem.defaultPath, defaultItem.productionPath, { - force: false, // Don't overwrite existing files! - recursive: true, - }); - console.log( - color.green(`Synchronized missing files: ${defaultItem.productionPath}`), - ); - } else { - throw new Error( - 'FATAL: Unexpected default file format in `post-install.js#createDefaultFiles()`.', - ); - } - } catch (error) { - console.error( - color.red( - `FATAL: Could not write default ${defaultItem.type}: ${defaultItem.productionPath}`, - ), - error, - ); - } - } -} - -try { - // 0. Convert config.conf to config.yaml - convertConfig(); - // 1. Create default config files - createDefaultFiles(); - // 2. Add missing config values - addMissingConfigValues(path.join(process.cwd(), './config.yaml')); -} catch (error) { - console.error(error); -} diff --git a/public/css/!USER-CSS-README.md b/public/css/!USER-CSS-README.md new file mode 100644 index 000000000..2fddf8644 --- /dev/null +++ b/public/css/!USER-CSS-README.md @@ -0,0 +1,7 @@ +# Looking for user.css? + +user.css is now located under your data root directory in the "_css" folder. + +Example for the default data root: + +/data/_css/user.css diff --git a/public/css/extensions-panel.css b/public/css/extensions-panel.css index ef662fdfe..528fd11ae 100644 --- a/public/css/extensions-panel.css +++ b/public/css/extensions-panel.css @@ -97,13 +97,23 @@ label[for="extensions_autoconnect"] { font-size: 1.05em; } -.extensions_info .extension_version { +.extensions_info :is(.extension_version, .extension_author) { opacity: 0.8; font-size: 0.8em; font-weight: normal; margin-left: 2px; } +.extensions_info :is(.extension_version, .extension_author):empty { + display: none; +} + +.extensions_info .extension_author { + display: inline-flex; + gap: 2px; + align-items: baseline; +} + .extensions_info .extension_block a { color: var(--SmartThemeBodyColor); } @@ -161,4 +171,4 @@ input.extension_missing[type="checkbox"] { z-index: 1; margin-bottom: 10px; padding: 5px; -} \ No newline at end of file +} diff --git a/public/css/mobile-styles.css b/public/css/mobile-styles.css index 845c32f23..b3d1e99ea 100644 --- a/public/css/mobile-styles.css +++ b/public/css/mobile-styles.css @@ -500,6 +500,11 @@ .horde_multiple_hint { display: none; } + + select[multiple] { + max-height: 50px; + overflow-y: auto; + } } /*landscape mode phones and ipads*/ diff --git a/public/css/streaming-display.css b/public/css/streaming-display.css new file mode 100644 index 000000000..e3f5148f1 --- /dev/null +++ b/public/css/streaming-display.css @@ -0,0 +1,236 @@ +/* ───────────────────────────────────────────────────────────────────────────── + Streaming Display — floating toast panel for live LLM generation output. + Shows reasoning (thinking) and content as they stream in. + Used by extensions that leverage ConnectionManagerRequestService streaming. + ───────────────────────────────────────────────────────────────────────────── */ + +.streaming-display { + position: fixed; + bottom: max(calc(var(--bottomFormBlockSize) + 5px), 20px); + right: 20px; + width: min(550px, calc(100vw - 40px)); + max-height: 70vh; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 10px; + padding: 14px; + z-index: 9000; + display: flex; + flex-direction: column; + gap: 10px; + opacity: 0; + transform: translateY(20px); + transition: opacity var(--animation-duration, 125ms) ease, transform var(--animation-duration, 125ms) ease; + overflow: hidden; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(12px); +} + +.streaming-display-visible { + opacity: 1; + transform: translateY(0); +} + +/* Header label with animated activity indicator */ +.streaming-display-label { + font-weight: 600; + font-size: 0.95em; + color: var(--SmartThemeBodyColor); + display: flex; + align-items: center; + gap: 8px; + user-select: none; +} + +/* LED status indicator - pulsing while streaming */ +.streaming-display-led { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: rgb(225, 138, 36); + animation: streaming-display-pulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +/* Completed state: solid green LED */ +.streaming-display-complete .streaming-display-led { + background: #4caf50; + animation: none; + opacity: 1; + box-shadow: 0 0 8px rgba(76, 175, 80, 0.6); +} + +/* Stopped state: solid red LED */ +.streaming-display-stopped .streaming-display-led { + background: #f44336; + animation: none; + opacity: 1; + box-shadow: 0 0 8px rgba(244, 67, 54, 0.6); +} + +@keyframes streaming-display-pulse { + 0%, 100% { opacity: 0.4; transform: scale(0.9); } + 50% { opacity: 1; transform: scale(1.1); } +} + +/* Label text takes available space */ +.streaming-display-label-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Window control buttons container */ +.streaming-display-controls { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + flex-shrink: 0; +} + +/* Window control buttons */ +.streaming-display-btn { + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--SmartThemeBodyColor); + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; + transition: opacity 0.15s ease, background-color 0.15s ease; +} + +.streaming-display-btn:hover { + opacity: 1; + background-color: rgba(255, 255, 255, 0.1); +} + +.streaming-display-btn-close:hover { + background-color: rgba(244, 67, 54, 0.2); +} + +.streaming-display-btn-stop { + font-size: 10px; +} + +.streaming-display-btn-stop:hover { + background-color: rgba(244, 150, 36, 0.2); + color: rgb(225, 138, 36); +} + +/* Content container - collapsible for minimize */ +.streaming-display-content { + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; + transition: max-height var(--animation-duration, 125ms) ease, opacity var(--animation-duration, 125ms) ease; +} + +/* Minimized state - hide content sections */ +.streaming-display-minimized .streaming-display-content { + max-height: 0; + opacity: 0; +} + +.streaming-display-minimized { + gap: 0; +} + +/* Model/API icon in the label */ +.streaming-display-icon { + width: 1.1em; + height: 1.1em; + flex-shrink: 0; +} + +/* Minimized state adjustments */ +.streaming-display-minimized.streaming-display { + padding: 10px 14px; +} + +/* Reasoning (thinking) section */ +.streaming-display-reasoning { + background: color-mix(in srgb, var(--SmartThemeBodyColor) 5%, transparent); + border-radius: 6px; + padding: 8px 10px; + border-left: 3px solid color-mix(in srgb, var(--SmartThemeBodyColor) 25%, transparent); +} + +.streaming-display-reasoning-label { + font-size: 0.8em; + font-weight: 600; + opacity: 0.5; + margin-bottom: 4px; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.streaming-display-reasoning-content { + font-size: 0.82em; + opacity: 0.65; + max-height: 25vh; + overflow-y: auto; + word-break: break-word; + line-height: 1.45; + scrollbar-width: thin; +} + +.streaming-display-reasoning-content p { + margin: 0.3em 0; +} + +.streaming-display-reasoning-content p:first-child { + margin-top: 0; +} + +.streaming-display-reasoning-content p:last-child { + margin-bottom: 0; +} + +/* Main content section */ +.streaming-display-text { + border-top: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 50%, transparent); + padding-top: 8px; + min-height: 1.5em; +} + +.streaming-display-text-content { + font-size: 0.9em; + color: var(--SmartThemeBodyColor); + max-height: 40vh; + overflow-y: auto; + word-break: break-word; + line-height: 1.5; + scrollbar-width: thin; +} + +.streaming-display-text-content p { + margin: 0.4em 0; +} + +.streaming-display-text-content p:first-child { + margin-top: 0; +} + +.streaming-display-text-content p:last-child { + margin-bottom: 0; +} + +/* Strip auto-added quotes from tags, as message formatting adds them */ +.streaming-display-reasoning-content q:before, +.streaming-display-reasoning-content q:after, +.streaming-display-text-content q:before, +.streaming-display-text-content q:after { + content: ''; +} diff --git a/public/css/toggle-dependent.css b/public/css/toggle-dependent.css index 271ea21e9..731f57cc9 100644 --- a/public/css/toggle-dependent.css +++ b/public/css/toggle-dependent.css @@ -565,6 +565,10 @@ label[for="bind_preset_to_connection"]:has(input:checked) { display: none; } +#openai_settings:has(#openai_function_calling:not(:checked)) #tool_call_recurse_limit_block { + display: none; +} + #adaptive_p_block:has([data-tg-samplers="adaptive_target"][style*="display: none"]):has([data-tg-samplers="adaptive_decay"][style*="display: none"]) { display: none; } diff --git a/public/css/welcome.css b/public/css/welcome.css index 9f292ed57..b5e43cd6d 100644 --- a/public/css/welcome.css +++ b/public/css/welcome.css @@ -17,6 +17,7 @@ .welcomePanel.recentHidden .welcomeRecent, .welcomePanel.recentHidden .recentChatsTitle, .welcomePanel.recentHidden .hideRecentChats, +.welcomePanel.recentHidden .recentChatsSettings, .welcomePanel:not(.recentHidden) .showRecentChats { display: none; } diff --git a/public/img/kobold.svg b/public/img/kobold.svg index 9e543d633..624cf78f7 100644 --- a/public/img/kobold.svg +++ b/public/img/kobold.svg @@ -1,78 +1,6 @@ - - - - - - - - - - - - - - - - - + + + + + diff --git a/public/img/koboldcpp.svg b/public/img/koboldcpp.svg index 9e543d633..624cf78f7 100644 --- a/public/img/koboldcpp.svg +++ b/public/img/koboldcpp.svg @@ -1,78 +1,6 @@ - - - - - - - - - - - - - - - - - + + + + + diff --git a/public/img/koboldhorde.svg b/public/img/koboldhorde.svg index 46a71498c..13f0bf9f0 100644 --- a/public/img/koboldhorde.svg +++ b/public/img/koboldhorde.svg @@ -1,48 +1,7 @@ - - - - - - - - + + + + + + diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 000000000..8798833e7 --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,2 @@ + + diff --git a/public/img/minimax.svg b/public/img/minimax.svg new file mode 100644 index 000000000..1d32449ab --- /dev/null +++ b/public/img/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/public/img/pollinations.svg b/public/img/pollinations.svg index dad71c9d3..267226bbe 100644 --- a/public/img/pollinations.svg +++ b/public/img/pollinations.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/public/img/scale.svg b/public/img/scale.svg index 5d0f87cea..a9cad3d98 100644 --- a/public/img/scale.svg +++ b/public/img/scale.svg @@ -1,60 +1,2 @@ - - - - + + diff --git a/public/img/textgenerationwebui.svg b/public/img/textgenerationwebui.svg index b453a385e..230b45d49 100644 --- a/public/img/textgenerationwebui.svg +++ b/public/img/textgenerationwebui.svg @@ -1,88 +1,7 @@ - - - - -Created by potrace 1.15, written by Peter Selinger 2001-2017 - - - - + + + Created by potrace 1.15, written by Peter Selinger 2001-2017 + + + diff --git a/public/img/workers_ai.svg b/public/img/workers_ai.svg new file mode 100644 index 000000000..14f73da0d --- /dev/null +++ b/public/img/workers_ai.svg @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/public/index.html b/public/index.html index f82f5bb4c..af2d4ef94 100644 --- a/public/index.html +++ b/public/index.html @@ -697,7 +697,7 @@ -
+
Temperature
@@ -710,7 +710,7 @@
-
+
Frequency Penalty
@@ -723,7 +723,7 @@
-
+
Presence Penalty
@@ -736,7 +736,7 @@
-
+
Top K
@@ -749,7 +749,7 @@
-
+
Top P
@@ -762,7 +762,7 @@
-
+
Repetition Penalty
@@ -986,7 +986,7 @@
-
+
Seed
@@ -1269,8 +1269,8 @@ Top K
- - + +
@@ -1400,7 +1400,7 @@
- -
+
+
+
+ Tool Call Recurse Limit +
+
+
+
+ +
+
+ +
+
+
-
+
-
+
-
+
-
+
-
+
@@ -2894,6 +2910,7 @@ + @@ -2904,6 +2921,7 @@ + @@ -3028,8 +3046,17 @@

OpenAI Model

+ @@ -3176,7 +3204,8 @@ Click "Authorize" below or get the key from OpenRouter.
- View Remaining Credits + View Remaining Credits +
@@ -3195,36 +3224,6 @@ Allow fallback models -
-
-
- OpenRouter Model Sorting -
-
-
-
- -
-
- -
- - Put OpenAI models in one group, Anthropic models in other group, etc. Can be combined with sorting. - -
-
-
-
-

Model Providers @@ -3344,6 +3343,8 @@ + + @@ -3566,6 +3567,32 @@

+
+

MiniMax API Key

+
+ + +
+
+ For privacy reasons, your API key will be hidden after you click 'Connect'. +
+

MiniMax Endpoint

+ +

MiniMax Model

+ +

Electron Hub API Key

@@ -3584,37 +3611,6 @@
-
-
-
- Electron Hub Model Sorting -
-
-
-
- -
-
- -
- - Put OpenAI models in one group, Anthropic models in other group, etc. Can be combined with sorting. - -
-
-
-
-

Chutes API Key

@@ -3634,29 +3630,11 @@
-
-
-
- Chutes Model Sorting -
-
-
-
- -
-
-
-

NanoGPT API Key

+ View Remaining Credits +
@@ -3670,6 +3648,39 @@
+
+

+ Model Providers + +

+ + +
+
+
+

Cloudflare Workers AI API Key

+
+ + +
+
+ For privacy reasons, your API key will be hidden after you click 'Connect'. +
+

Cloudflare Account ID

+
+ +
+
+

Workers AI Model

+ +

DeepSeek API Key

@@ -3683,9 +3694,7 @@

DeepSeek Model

@@ -3924,6 +3933,7 @@

Z.AI Model

+ + + + + + +
+
+ +
+ + Put OpenAI models in one group, Anthropic models in other group, etc. Can be combined with sorting. + +
+
+
+
+

@@ -4365,7 +4406,7 @@ First User Prefix

-
+
Last User Prefix
@@ -4775,7 +4816,7 @@ or ` : - ``; + // Icon + const iconDiv = document.createElement('div'); + iconDiv.classList.add('extension_icon'); + iconDiv.appendChild(getExtensionIcon()); + block.appendChild(iconDiv); - let deleteButton = isExternal ? `` : ''; - let updateButton = isExternal ? `` : ''; - let moveButton = isExternal && isUserAdmin ? `` : ''; - let branchButton = isExternal && isUserAdmin ? `` : ''; - let modulesInfo = ''; + // Text block + const textBlock = document.createElement('div'); + textBlock.classList.add('flexGrow', 'extension_text_block'); + + const statusSpan = document.createElement('span'); + statusSpan.className = isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'; + + const nameSpan = document.createElement('span'); + nameSpan.classList.add('extension_name'); + nameSpan.textContent = displayName; + + const authorSpan = document.createElement('span'); + authorSpan.classList.add('extension_author'); + + const versionSpan = document.createElement('span'); + versionSpan.classList.add('extension_version'); + versionSpan.textContent = displayVersion; + + statusSpan.append(nameSpan, authorSpan, versionSpan); if (isActive && Array.isArray(manifest.optional)) { const optional = new Set(manifest.optional); modules.forEach(x => optional.delete(x)); if (optional.size > 0) { - const optionalString = DOMPurify.sanitize([...optional].join(', ')); - modulesInfo = '
' + t`Optional modules:` + ` ${optionalString}
`; + const modulesDiv = document.createElement('div'); + modulesDiv.classList.add('extension_modules'); + const optionalSpan = document.createElement('span'); + optionalSpan.classList.add('optional'); + optionalSpan.textContent = [...optional].join(', '); + modulesDiv.append(t`Optional modules:`, ' ', optionalSpan); + statusSpan.appendChild(modulesDiv); } - } else if (!isDisabled) { // Neither active nor disabled + } else if (!isDisabled) { + // Neither active nor disabled const requirements = new Set(manifest.requires); modules.forEach(x => requirements.delete(x)); if (requirements.size > 0) { - const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); - modulesInfo = `
Missing modules: ${requirementsString}
`; + const modulesDiv = document.createElement('div'); + modulesDiv.classList.add('extension_modules'); + const failureSpan = document.createElement('span'); + failureSpan.classList.add('failure'); + failureSpan.textContent = [...requirements].join(', '); + modulesDiv.append(t`Missing modules:`, ' ', failureSpan); + statusSpan.appendChild(modulesDiv); } } // if external, wrap the name in a link to the repo + if (isExternal) { + const originLink = document.createElement('a'); + originLink.appendChild(statusSpan); + textBlock.appendChild(originLink); + } else { + textBlock.appendChild(statusSpan); + } - let extensionHtml = ` -
-
- ${toggleElement} -
-
- ${extensionIcon} -
-
- ${originHtml} - - ${DOMPurify.sanitize(displayName)} - ${DOMPurify.sanitize(displayVersion)} - ${modulesInfo} - - ${isExternal ? '' : ''} -
+ block.appendChild(textBlock); -
- ${updateButton} - ${branchButton} - ${moveButton} - ${deleteButton} -
-
`; + // Actions + const actionsDiv = document.createElement('div'); + actionsDiv.classList.add('extension_actions', 'flex-container', 'alignItemsCenter'); - return extensionHtml; + /** + * Helper function to create an action button for an extension. + * @param {string} cls Class name + * @param {string} dataName Name of the extension + * @param {string} title Title of the button + * @param {string} iconClasses Classes for the icon + * @returns {HTMLButtonElement} The created button element + */ + function makeActionButton(cls, dataName, title, iconClasses) { + const btn = document.createElement('button'); + btn.classList.add(cls, 'menu_button'); + btn.dataset.name = dataName; + btn.title = title; + const icon = document.createElement('i'); + icon.classList.add(...iconClasses.split(' ')); + btn.appendChild(icon); + return btn; + } + + if (isExternal) { + const updateBtn = makeActionButton('btn_update', externalId, t`Update available`, 'fa-solid fa-download fa-fw'); + updateBtn.classList.add('displayNone'); + actionsDiv.appendChild(updateBtn); + } + + if (isExternal && hasExtensionHook(externalId, 'clean')) { + actionsDiv.appendChild(makeActionButton('btn_clean', externalId, t`Clean extension data`, 'fa-fw fa-solid fa-broom')); + } + + if (isExternal && isUserAdmin) { + actionsDiv.appendChild(makeActionButton('btn_branch', externalId, t`Switch branch`, 'fa-solid fa-code-branch fa-fw')); + actionsDiv.appendChild(makeActionButton('btn_move', externalId, t`Move`, 'fa-solid fa-folder-tree fa-fw')); + } + + if (isExternal) { + actionsDiv.appendChild(makeActionButton('btn_delete', externalId, t`Delete`, 'fa-fw fa-solid fa-trash-can')); + } + + block.appendChild(actionsDiv); + + return block; } /** - * Gets extension data and generates the corresponding HTML for displaying the extension. + * Gets extension data and generates the corresponding element for displaying the extension. * * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. - * @return {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. + * @return {{isExternal: boolean, extensionElement: HTMLElement}} - An object with 'isExternal' indicating whether the extension is external, and 'extensionElement' for the extension's HTML element. */ function getExtensionData(extension) { const name = extension[0]; @@ -927,33 +1068,43 @@ function getExtensionData(extension) { const isExternal = name.startsWith('third-party'); const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; + const extensionElement = generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass); - const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); - - return { isExternal, extensionHtml }; + return { isExternal, extensionElement }; } /** * Gets the module information to be displayed. * - * @return {string} - The HTML string for the module information. + * @return {HTMLElement} - The element containing the module information. */ function getModuleInformation() { - let moduleInfo = modules.length ? `

${DOMPurify.sanitize(modules.join(', '))}

` : '

' + t`Not connected to the API!` + '

'; - return ` -

` + t`Modules provided by your Extras API:` + `

- ${moduleInfo} - `; + const container = document.createElement('div'); + + const heading = document.createElement('h3'); + heading.textContent = t`Modules provided by your Extras API:`; + container.appendChild(heading); + + const moduleInfo = document.createElement('p'); + if (modules.length) { + moduleInfo.textContent = modules.join(', '); + } else { + moduleInfo.classList.add('failure'); + moduleInfo.textContent = t`Not connected to the API!`; + } + container.appendChild(moduleInfo); + + return container; } /** - * Generates HTML for the extension load errors. - * @returns {string} HTML string containing the errors that occurred while loading extensions. + * Generates HTMLElement for the extension load errors. + * @returns {HTMLElement} - The element containing the extension load errors. */ -function getExtensionLoadErrorsHtml() { +function getExtensionLoadErrors() { if (extensionLoadErrors.size === 0) { - return ''; + return document.createElement('div'); } const container = document.createElement('div'); @@ -965,7 +1116,7 @@ function getExtensionLoadErrorsHtml() { container.appendChild(errorElement); } - return container.outerHTML; + return container; } /** @@ -982,22 +1133,35 @@ async function showExtensionsDetails() { initialScrollTop = oldPopup.content.scrollTop; await oldPopup.completeCancelled(); } - const htmlErrors = getExtensionLoadErrorsHtml(); - const htmlDefault = $('

' + t`Built-in Extensions:` + '

'); + const errors = getExtensionLoadErrors(); - const htmlExternal = $(`
-
-

${t`Installed Extensions:`}

-
-
-
`); + const defaultContainer = document.createElement('div'); + defaultContainer.classList.add('marginBot10'); + const defaultHeading = document.createElement('h3'); + defaultHeading.textContent = t`Built-in Extensions:`; + defaultContainer.appendChild(defaultHeading); - const htmlLoading = $(`
- - ` + t`Loading third-party extensions... Please wait...` + ` -
`); + const externalContainer = document.createElement('div'); + externalContainer.classList.add('marginBot10'); + const externalHeader = document.createElement('div'); + externalHeader.classList.add('flex-container', 'alignitemscenter', 'spaceBetween', 'flexnowrap', 'marginBot10'); + const externalHeading = document.createElement('h3'); + externalHeading.classList.add('margin0'); + externalHeading.textContent = t`Installed Extensions:`; + const thirdPartyToolbar = document.createElement('div'); + thirdPartyToolbar.classList.add('flex-container', 'third_party_toolbar'); + externalHeader.append(externalHeading, thirdPartyToolbar); + externalContainer.appendChild(externalHeader); - htmlExternal.append(htmlLoading); + const loadingEl = document.createElement('div'); + loadingEl.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'marginTop10', 'marginBot5'); + const loadingIcon = document.createElement('i'); + loadingIcon.classList.add('fa-solid', 'fa-spinner', 'fa-spin'); + const loadingSpan = document.createElement('span'); + loadingSpan.textContent = t`Loading third-party extensions... Please wait...`; + loadingEl.append(loadingIcon, loadingSpan); + + externalContainer.appendChild(loadingEl); const sortOrderKey = 'extensions_sortByName'; const sortByName = accountStorage.getItem(sortOrderKey) === 'true'; @@ -1006,16 +1170,16 @@ async function showExtensionsDetails() { let extensionsToToggle = []; extensions.forEach(value => { - const { isExternal, extensionHtml } = value; - const container = isExternal ? htmlExternal : htmlDefault; - container.append(extensionHtml); + const { isExternal, extensionElement } = value; + const container = isExternal ? externalContainer : defaultContainer; + container.appendChild(extensionElement); }); - const html = $('
') + const extensionsMenu = $('
') .addClass('extensions_info') - .append(htmlErrors) - .append(htmlDefault) - .append(htmlExternal) + .append(errors) + .append(defaultContainer) + .append(externalContainer) .append(getModuleInformation()); { @@ -1041,23 +1205,24 @@ async function showExtensionsDetails() { const toggleAllExtensionsButton = document.createElement('div'); toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon'); toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`; - toggleAllExtensionsButton.innerHTML = ` - ${t`Toggle extensions`} -
- `; + const toggleAllLabel = document.createElement('span'); + toggleAllLabel.textContent = t`Toggle extensions`; + const toggleAllIcon = document.createElement('div'); + toggleAllIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p'); + toggleAllExtensionsButton.append(toggleAllLabel, toggleAllIcon); const restoreBulkToggledExtensionsButton = document.createElement('div'); restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone'); restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`; toggleAllExtensionsButton.addEventListener('click', () => { - extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal); + extensionsToToggle = onToggleAllExtensions(extensionsToToggle, $(externalContainer)); for (const extension of extensionsToToggle) { const { name } = extension; - htmlExternal - .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`) + $(externalContainer) + .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`) .off('click') .one('click', () => { extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name); @@ -1074,8 +1239,8 @@ async function showExtensionsDetails() { const { name } = extension; const isDisabled = extension_settings.disabledExtensions.includes(name); - htmlExternal - .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`) + $(externalContainer) + .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`) .prop('checked', !isDisabled) .toggleClass('toggle_enable', isDisabled) .toggleClass('toggle_disable', !isDisabled) @@ -1099,13 +1264,13 @@ async function showExtensionsDetails() { }); toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton); - htmlExternal.find('.third_party_toolbar').append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton); - html.prepend(toolbar); + thirdPartyToolbar.append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton); + extensionsMenu.prepend(toolbar); } let waitingForSave = false; - const popup = new Popup(html, POPUP_TYPE.TEXT, '', { + const popup = new Popup(extensionsMenu, POPUP_TYPE.TEXT, '', { okButton: t`Close`, wide: true, large: true, @@ -1147,7 +1312,7 @@ async function showExtensionsDetails() { }); popupPromise = popup.show(); popup.content.scrollTop = initialScrollTop; - checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoading.remove()); + checkForUpdatesManual(sortFn, abortController.signal).finally(() => loadingEl.remove()); } catch (error) { toastr.error(t`Error loading extensions. See browser console for details.`); console.error(error); @@ -1234,6 +1399,7 @@ async function updateExtension(extensionName, quiet, timeout = null) { * This function makes a POST request to '/api/extensions/delete' with the extension's name. * If the extension is deleted, it displays a success message. * Creates a popup for the user to confirm before delete. + * If the extension has a 'clean' hook, an optional checkbox to also run the cleanup is shown. */ async function onDeleteClick() { const extensionName = $(this).data('name'); @@ -1244,13 +1410,50 @@ async function onDeleteClick() { return; } - // use callPopup to create a popup for the user to confirm before delete - const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {}); + const hasCleanHook = hasExtensionHook(extensionName, 'clean'); + + /** @type {import('./popup.js').CustomPopupInput[]} */ + const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null; + + const popup = new Popup(t`Are you sure you want to delete ${escapeHtml(extensionName)}?`, POPUP_TYPE.CONFIRM, '', { customInputs }); + const confirmation = await popup.show(); if (confirmation === POPUP_RESULT.AFFIRMATIVE) { - await deleteExtension(extensionName); + const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup')); + await deleteExtension(extensionName, shouldClean); } } +/** + * Handles the click event for the clean button of an extension. + * Runs the extension's 'clean' hook after user confirmation, then reloads the page. + */ +async function onCleanClick() { + const extensionName = $(this).data('name'); + + const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${escapeHtml(extensionName)}? This action cannot be undone.`); + if (!confirmation) { + return; + } + + await cleanExtension(extensionName); +} + +/** + * Runs the 'clean' hook for an extension and reloads the page. + * @param {string} extensionName Extension name (without 'third-party' prefix) + * @returns {Promise} + */ +async function cleanExtension(extensionName) { + const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`; + await callExtensionHook(fullExtensionName, 'clean'); + + // Clean might have updated settings, which could race with the page reload, so we'll force save here + await saveSettings(); + + toastr.success(t`Extension ${extensionName} data cleaned`); + delay(1000).then(() => location.reload()); +} + async function onBranchClick() { const extensionName = $(this).data('name'); const isCurrentUserAdmin = isAdmin(); @@ -1303,8 +1506,8 @@ async function onMoveClick() { const confirmationHeader = t`Move extension`; const confirmationText = source == 'global' - ? t`Are you sure you want to move ${extensionName} to your local extensions? This will make it available only for you.` - : t`Are you sure you want to move ${extensionName} to the global extensions? This will make it available for all users.`; + ? t`Are you sure you want to move ${escapeHtml(extensionName)} to your local extensions? This will make it available only for you.` + : t`Are you sure you want to move ${escapeHtml(extensionName)} to the global extensions? This will make it available for all users.`; const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText); @@ -1353,9 +1556,16 @@ async function moveExtension(extensionName, source, destination) { /** * Deletes an extension via the API. * @param {string} extensionName Extension name to delete + * @param {boolean} [shouldClean=false] Whether to also run the 'clean' hook before deleting */ -export async function deleteExtension(extensionName) { - await callExtensionHook(extensionName, 'delete'); +export async function deleteExtension(extensionName, shouldClean = false) { + const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`; + + if (shouldClean) { + await callExtensionHook(fullExtensionName, 'clean'); + } + + await callExtensionHook(fullExtensionName, 'delete'); try { await fetch('/api/extensions/delete', { @@ -1370,6 +1580,9 @@ export async function deleteExtension(extensionName) { console.error('Error:', error); } + // Delete or clean might have updated settings, which could race with the page reload, so we'll force save here + await saveSettings(); + toastr.success(t`Extension ${extensionName} deleted`); delay(1000).then(() => location.reload()); } @@ -1398,6 +1611,9 @@ async function getExtensionVersion(extensionName, abortSignal) { const data = await response.json(); return data; } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } console.error('Error:', error); } } @@ -1476,9 +1692,53 @@ async function switchExtensionBranch(extensionName, isGlobal, branch) { * Installs a third-party extension via the API. * @param {string} url Extension repository URL * @param {boolean} global Is the extension global? - * @returns {Promise} + * @param {string} [branch] Optional branch to install, if not provided the default branch will be used + * @returns {Promise} True if the extension was installed successfully, false otherwise */ export async function installExtension(url, global, branch = '') { + try { + const parsedUrl = new URL(url); + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new Error('Invalid URL protocol'); + } + + // Normalize the URL (resolve relative paths, remove redundant segments, etc.) + url = parsedUrl.href; + } catch (error) { + console.error('Invalid URL:', error); + toastr.error(t`Only valid HTTP and HTTPS URLs are allowed.`, t`Invalid URL`); + return false; + } + + if (!isOfficialExtension(url)) { + const extensionInstallationWarningKey = 'extensionInstallationWarningShown'; + if (accountStorage.getItem(extensionInstallationWarningKey)) { + console.debug('Bypassed URL check for third-party extension (account preference).', url); + } else { + let dismissWarning = false; + const confirmation = await Popup.show.confirm( + t`Install a third-party extension?`, + await renderTemplateAsync('thirdPartyExtensionWarning'), + { + customInputs: [{ id: 'dontAskAgain', type: 'checkbox', label: t`Don't show this warning again`, defaultState: false }], + onClose: (popup) => { + if (!popup.result) { + return; + } + dismissWarning = Boolean(popup.inputResults?.get('dontAskAgain') ?? false); + }, + okButton: t`Yes, install it`, + cancelButton: t`No, cancel`, + }); + if (!confirmation) { + return false; + } + if (dismissWarning) { + accountStorage.setItem(extensionInstallationWarningKey, '1'); + } + } + } + console.debug('Extension installation started', url); toastr.info(t`Please wait...`, t`Installing extension`); @@ -1497,11 +1757,11 @@ export async function installExtension(url, global, branch = '') { const text = await request.text(); toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 }); console.error('Extension installation failed', request.status, request.statusText, text); - return; + return false; } const response = await request.json(); - toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`); + toastr.success(t`Extension '${response.display_name}' has been installed successfully!`, t`Extension installation successful`); console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`); await loadExtensionSettings({}, false, false); await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response); @@ -1510,6 +1770,8 @@ export async function installExtension(url, global, branch = '') { const extensionName = `third-party/${response.folderName}`; await callExtensionHook(extensionName, 'install'); } + + return true; } /** @@ -1589,7 +1851,11 @@ async function checkForUpdatesManual(sortFn, abortSignal) { const promise = enqueueVersionCheck(async () => { try { const data = await getExtensionVersion(externalId, abortSignal); - const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`); + if (!data) { + return; + } + const selector = getNameSelector(externalId, { prefix: '' }); + const extensionBlock = document.querySelector(`.extension_block[data-name="${selector}"]`); if (extensionBlock && data) { if (data.isUpToDate === false) { const buttonElement = extensionBlock.querySelector('.btn_update'); @@ -1620,6 +1886,18 @@ async function checkForUpdatesManual(sortFn, abortSignal) { } } + const authorElement = extensionBlock.querySelector('.extension_author'); + if (authorElement) { + const author = getAuthorFromUrl(origin) || EMPTY_AUTHOR; + if (author.name) { + const icon = document.createElement('i'); + icon.classList.add('fa-solid', 'fa-at', 'fa-xs'); + const name = document.createElement('span'); + name.textContent = author.name; + authorElement.append(icon, name); + } + } + const versionElement = extensionBlock.querySelector('.extension_version'); if (versionElement) { versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`; @@ -1672,6 +1950,9 @@ async function checkForExtensionUpdates(force) { const promise = enqueueVersionCheck(async () => { try { const data = await getExtensionVersion(id.replace('third-party', '')); + if (!data) { + return; + } if (!data.isUpToDate) { updatesAvailable.push(manifest.display_name); } @@ -1758,6 +2039,18 @@ export async function runGenerationInterceptors(chat, contextSize, type) { return aborted; } +/** + * Sentinel value that signals a field should be completely removed (unset) + * from the character card rather than being set to any value. Pass this as + * the `value` argument to {@link writeExtensionField} or + * {@link writeExtensionFieldBulk} to delete the key entirely. + * + * Using `null` as a value will set the field to `null` (the key remains). + * Using this sentinel will delete the key from the character card. + * @type {string} + */ +export const UNSET_VALUE = '__@@UNSET@@__'; + /** * Writes a field to the character's data extensions object. * @param {number|string} characterId Index in the character array @@ -1772,13 +2065,23 @@ export async function writeExtensionField(characterId, key, value) { console.warn('Character not found', characterId); return; } - const path = `data.extensions.${key}`; - setValueByPath(character, path, value); + const extensionPath = `data.extensions.${key}`; + const isUnset = value === UNSET_VALUE; + + if (isUnset) { + deleteValueByPath(character, extensionPath); + } else { + setValueByPath(character, extensionPath, value); + } // Process JSON data if (character.json_data) { const jsonData = JSON.parse(character.json_data); - setValueByPath(jsonData, path, value); + if (isUnset) { + deleteValueByPath(jsonData, extensionPath); + } else { + setValueByPath(jsonData, extensionPath, value); + } character.json_data = JSON.stringify(jsonData); // Make sure the data doesn't get lost when saving the current character @@ -1807,6 +2110,107 @@ export async function writeExtensionField(characterId, key, value) { } } +/** + * @typedef {object} BulkExtensionFieldResult + * @property {string[]} updated Avatar filenames that were successfully updated + * @property {string[]} skipped Avatar filenames skipped (filter didn't match or unreadable) + * @property {string[]} failed Avatar filenames where the update failed + */ + +/** + * Writes (or deletes) an extension field for multiple characters in a single + * bulk request. Unlike {@link writeExtensionField}, this sends one API call + * for all characters, and the server processes them in parallel. + * + * When `value` is {@link UNSET_VALUE} the extension key is **deleted** from + * each matching character card. Passing `null` sets the field to `null` + * (the key is preserved). + * + * @param {string[]|null} avatars Avatar filenames to update. Pass `null` or an + * empty array to target **all** characters in the user's character directory. + * @param {string} key Extension field name (e.g. "greeting_tools") + * @param {any} value Field value, `null` to set null, or + * {@link UNSET_VALUE} to delete the key entirely + * @param {object} [options={}] Optional settings + * @param {string} [options.filterPath] Dot-path filter — the server will only + * update characters where this path is present and not `undefined`; + * `null` still counts as a match. Useful when the frontend has shallow + * character data and cannot pre-filter. + * Defaults to `data.extensions.` when unsetting, so deletion requests + * automatically skip characters where the field is missing/`undefined`. + * @returns {Promise} Summary of the bulk operation + */ +export async function writeExtensionFieldBulk(avatars, key, value, { filterPath } = {}) { + const context = getContext(); + const extensionPath = `data.extensions.${key}`; + const isUnset = value === UNSET_VALUE; + + // Build the server request + const requestBody = { + avatars: Array.isArray(avatars) && avatars.length > 0 ? avatars : [], + data: { + data: { + extensions: { + [key]: value, + }, + }, + }, + }; + + // Default filter: when unsetting, only touch characters that have the field + const resolvedFilterPath = filterPath ?? (isUnset ? extensionPath : undefined); + if (resolvedFilterPath) { + requestBody.filter = { path: resolvedFilterPath }; + } + + const mergeResponse = await fetch('/api/characters/merge-attributes', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(requestBody), + }); + + if (!mergeResponse.ok) { + console.error('Bulk extension field update failed', mergeResponse.statusText); + return { updated: [], skipped: [], failed: [] }; + } + + /** @type {BulkExtensionFieldResult} */ + const result = await mergeResponse.json(); + + // Sync in-memory character objects for successfully updated characters + const updatedSet = new Set(result.updated); + for (const character of context.characters) { + if (!character || !updatedSet.has(character.avatar)) continue; + + if (isUnset) { + deleteValueByPath(character, extensionPath); + } else { + setValueByPath(character, extensionPath, value); + } + + // Keep json_data in sync + if (character.json_data) { + const jsonData = JSON.parse(character.json_data); + if (isUnset) { + deleteValueByPath(jsonData, extensionPath); + } else { + setValueByPath(jsonData, extensionPath, value); + } + character.json_data = JSON.stringify(jsonData); + } + } + + // If the currently active character was updated, sync the hidden input + if (context.characterId !== undefined) { + const activeChar = context.characters[context.characterId]; + if (activeChar && updatedSet.has(activeChar.avatar) && activeChar.json_data) { + $('#character_json_data').val(activeChar.json_data); + } + } + + return result; +} + /** * Prompts the user to enter the Git URL of the extension to import. * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension. @@ -1853,6 +2257,39 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') { await installExtension(url, global, branchName); } +/** + * Sentinel value representing an empty author, used when author information cannot be extracted from a URL. + * @type {{name: string, url: string}} + */ +export const EMPTY_AUTHOR = Object.freeze({ + name: '', + url: '', +}); + +/** + * Extracts the repository author from a given URL. + * @param {string} url - The URL of the repository. + * @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found. + */ +export function getAuthorFromUrl(url) { + const result = structuredClone(EMPTY_AUTHOR); + + try { + const parsedUrl = new URL(url); + const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0); + + // TODO: Handle non-GitHub URLs if needed + if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) { + result.name = pathSegments[0]; + result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`; + } + } catch (error) { + console.debug('Error parsing URL:', error); + } + + return result; +} + export async function initExtensions() { await addExtensionsButtonAndMenu(); $('#extensionsMenuButton').css('display', 'flex'); @@ -1865,6 +2302,7 @@ export async function initExtensions() { $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); + $(document).on('click', '.extensions_info .extension_block .btn_clean', onCleanClick); $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick); diff --git a/public/scripts/extensions/assets/index.js b/public/scripts/extensions/assets/index.js index e72fc549f..7067d3497 100644 --- a/public/scripts/extensions/assets/index.js +++ b/public/scripts/extensions/assets/index.js @@ -5,12 +5,12 @@ TODO: import { DOMPurify } from '../../../lib.js'; import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js'; -import { deleteExtension, extensionNames, getContext, installExtension, renderExtensionTemplateAsync } from '../../extensions.js'; +import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js'; import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js'; -import { executeSlashCommandsWithOptions } from '../../slash-commands.js'; import { accountStorage } from '../../util/AccountStorage.js'; -import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js'; +import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js'; import { t, translate } from '../../i18n.js'; +import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js'; export { MODULE_NAME }; const MODULE_NAME = 'assets'; @@ -60,219 +60,262 @@ const KNOWN_TYPES = { 'blip': t`Blip sounds`, }; -const EMPTY_AUTHOR = { - name: '', - url: '', -}; - /** - * Extracts the repository author from a given URL. - * @param {string} url - The URL of the repository. - * @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found. + * Creates the download/delete button element for a single asset, with all interaction handlers attached. + * @param {object} asset The asset data object, containing at least id, name, description and url fields + * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip' + * @param {number} index Index of the asset in the list of available assets of the same type, used to create a unique element ID + * @returns {JQuery} The button element */ -function getAuthorFromUrl(url) { - const result = structuredClone(EMPTY_AUTHOR); +function createAssetButton(asset, assetType, index) { + const elemId = `assets_install_${assetType}_${index}`; + const element = $('
', { id: elemId, class: 'asset-download-button right_menu_button' }); + const label = $(''); + element.append(label); - try { - const parsedUrl = new URL(url); - const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0); + console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url); - // TODO: Handle non-GitHub URLs if needed - if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) { - result.name = pathSegments[0]; - result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`; + const assetInstall = async function () { + element.off('click'); + label.removeClass('fa-download'); + this.classList.add('asset-download-button-loading'); + const result = await installAsset(asset.url, assetType, asset.id); + if (!result) { + this.classList.remove('asset-download-button-loading'); + label.addClass('fa-download'); + label.removeClass('fa-spinner'); + label.removeClass('fa-spin'); + element.on('click', assetInstall); + return; } - } catch (error) { - console.debug(DEBUG_PREFIX, 'Error parsing URL:', error); + label.addClass('fa-check'); + this.classList.remove('asset-download-button-loading'); + element.on('click', assetDelete); + element.on('mouseenter', function () { + label.removeClass('fa-check'); + label.addClass('fa-trash'); + label.addClass('redOverlayGlow'); + }).on('mouseleave', function () { + label.addClass('fa-check'); + label.removeClass('fa-trash'); + label.removeClass('redOverlayGlow'); + }); + }; + + const assetDelete = async function () { + if (assetType === 'character') { + toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported'); + await SlashCommandParser.commands.go.callback(null, asset.id); + return; + } + element.off('click'); + await deleteAsset(assetType, asset.id); + label.removeClass('fa-check'); + label.removeClass('redOverlayGlow'); + label.removeClass('fa-trash'); + label.addClass('fa-download'); + element.off('mouseenter').off('mouseleave'); + element.on('click', assetInstall); + }; + + if (isAssetInstalled(assetType, asset.id)) { + console.debug(DEBUG_PREFIX, 'installed, checked'); + label.toggleClass('fa-download'); + label.toggleClass('fa-check'); + element.on('click', assetDelete); + element.on('mouseenter', function () { + label.removeClass('fa-check'); + label.addClass('fa-trash'); + label.addClass('redOverlayGlow'); + }).on('mouseleave', function () { + label.addClass('fa-check'); + label.removeClass('fa-trash'); + label.removeClass('redOverlayGlow'); + }); + } else { + console.debug(DEBUG_PREFIX, 'not installed, unchecked'); + element.prop('checked', false); + element.on('click', assetInstall); } - return result; + return element; } -async function downloadAssetsList(url) { - updateCurrentAssets().then(async function () { - fetch(url, { cache: 'no-cache' }) - .then(response => response.json()) - .then(async function (json) { - availableAssets = {}; - $('#assets_menu').empty(); +/** + * Creates the full visual block element for a single asset. + * @param {object} asset The asset data object, containing at least id, name, description and url fields + * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip' + * @param {JQuery} element The button element from createAssetButton + * @returns {JQuery} The asset block element + */ +function createAssetBlock(asset, assetType, element) { + console.debug(DEBUG_PREFIX, 'Created element for ', asset.id); - console.debug(DEBUG_PREFIX, 'Received assets dictionary', json); + const displayName = DOMPurify.sanitize(asset.name || asset.id); + const description = DOMPurify.sanitize(asset.description || ''); + const url = isValidUrl(asset.url) ? asset.url : ''; + const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`; + const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple'; + const toolTag = assetType === 'extension' && asset.tool; + const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR; - for (const i of json) { - //console.log(DEBUG_PREFIX,i) - if (availableAssets[i.type] === undefined) - availableAssets[i.type] = []; + const nameSpan = $('', { class: 'asset-name flex-container alignitemscenter' }) + .append($('').text(displayName)) + .append($('', { class: 'asset_preview', href: url, target: '_blank', title: title }) + .append($('', { class: `fa-solid fa-sm ${previewIcon}` }))); - availableAssets[i.type].push(i); - } + if (toolTag) { + const tagSpan = $('', { class: 'tag', title: t`Adds a function tool` }) + .append($('', { class: 'fa-solid fa-sm fa-wrench' })) + .append(document.createTextNode(` ${t`Tool`}`)); + nameSpan.append(tagSpan); + } - console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets); - // First extensions, then everything else - const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0); + nameSpan.append($('', { class: 'expander' })); - $('#assets_type_select').empty(); - $('#assets_search').val(''); - $('#assets_type_select').append($('', { href: author.url, target: '_blank', class: 'asset-author-info' }) + .append($('', { class: 'fa-solid fa-at fa-xs' })) + .append($('').text(author.name))); + } - for (const type of assetTypes) { - const text = translate(KNOWN_TYPES[type] || type); - const option = $('
', { class: 'flex-container flexFlowColumn flexNoGap wide100p overflowHidden' }) + .append(nameSpan) + .append($('', { class: 'asset-description' }).text(description)); - if (assetTypes.includes('extension')) { - $('#assets_type_select').val('extension'); - } + const assetBlock = $('').append(element).append(infoDiv); - $('#assets_type_select').off('change').on('change', filterAssets); - $('#assets_search').off('input').on('input', filterAssets); - - for (const assetType of assetTypes) { - let assetTypeMenu = $('
', { id: 'assets_audio_ambient_div', class: 'assets-list-div' }); - assetTypeMenu.attr('data-type', assetType); - assetTypeMenu.append(`

${KNOWN_TYPES[assetType] || assetType}

`).hide(); - - if (assetType == 'extension') { - assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation')); - } - - for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) { - const i = availableAssets[assetType].indexOf(asset); - const elemId = `assets_install_${assetType}_${i}`; - let element = $('
', { id: elemId, class: 'asset-download-button right_menu_button' }); - const label = $(''); - element.append(label); - - //if (DEBUG_TONY_SAMA_FORK_MODE) - // asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG - - console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url); - - const assetInstall = async function () { - element.off('click'); - label.removeClass('fa-download'); - this.classList.add('asset-download-button-loading'); - await installAsset(asset.url, assetType, asset.id); - label.addClass('fa-check'); - this.classList.remove('asset-download-button-loading'); - element.on('click', assetDelete); - element.on('mouseenter', function () { - label.removeClass('fa-check'); - label.addClass('fa-trash'); - label.addClass('redOverlayGlow'); - }).on('mouseleave', function () { - label.addClass('fa-check'); - label.removeClass('fa-trash'); - label.removeClass('redOverlayGlow'); - }); - }; - - const assetDelete = async function () { - if (assetType === 'character') { - toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported'); - await executeSlashCommandsWithOptions(`/go ${asset.id}`); - return; - } - element.off('click'); - await deleteAsset(assetType, asset.id); - label.removeClass('fa-check'); - label.removeClass('redOverlayGlow'); - label.removeClass('fa-trash'); - label.addClass('fa-download'); - element.off('mouseenter').off('mouseleave'); - element.on('click', assetInstall); - }; - - if (isAssetInstalled(assetType, asset.id)) { - console.debug(DEBUG_PREFIX, 'installed, checked'); - label.toggleClass('fa-download'); - label.toggleClass('fa-check'); - element.on('click', assetDelete); - element.on('mouseenter', function () { - label.removeClass('fa-check'); - label.addClass('fa-trash'); - label.addClass('redOverlayGlow'); - }).on('mouseleave', function () { - label.addClass('fa-check'); - label.removeClass('fa-trash'); - label.removeClass('redOverlayGlow'); - }); - } else { - console.debug(DEBUG_PREFIX, 'not installed, unchecked'); - element.prop('checked', false); - element.on('click', assetInstall); - } - - console.debug(DEBUG_PREFIX, 'Created element for ', asset.id); - - const displayName = DOMPurify.sanitize(asset.name || asset.id); - const description = DOMPurify.sanitize(asset.description || ''); - const url = isValidUrl(asset.url) ? asset.url : ''; - const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`; - const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple'; - const toolTag = assetType === 'extension' && asset.tool; - const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR; - - const assetBlock = $('') - .append(element) - .append(`
- - ${displayName} - - - ` + - (toolTag ? ' ' + - t`Tool` + '' : '') + - '' + - (author.name ? `${author.name}` : '') + - ` - - ${description} - -
`); - - assetBlock.find('.tag').on('click', function (e) { - const a = document.createElement('a'); - a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/'; - a.target = '_blank'; - a.click(); - }); - - if (assetType === 'character') { - if (asset.highlight) { - assetBlock.find('.asset-name').append(''); - } - assetBlock.find('.asset-name').prepend(`
${displayName}
`); - } - - assetBlock.addClass('asset-block'); - - assetTypeMenu.append(assetBlock); - } - assetTypeMenu.appendTo('#assets_menu'); - assetTypeMenu.on('click', 'a.asset_preview', previewAsset); - } - - filterAssets(); - $('#assets_filters').show(); - $('#assets_menu').show(); - }) - .catch((error) => { - // Info hint if the user maybe... likely accidently was trying to install an extension and we wanna help guide them? uwu :3 - const installButton = $('#third_party_extension_button'); - flashHighlight(installButton, 10_000); - toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 }); - - // Error logged after, to appear on top - console.error(error); - toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list'); - $('#assets-connect-button').addClass('fa-plug-circle-exclamation'); - $('#assets-connect-button').addClass('redOverlayGlow'); - }); + assetBlock.find('.tag').on('click', function (e) { + const a = document.createElement('a'); + a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/'; + a.target = '_blank'; + a.click(); }); + + if (assetType === 'character') { + if (asset.highlight) { + nameSpan.append($('', { class: 'fa-solid fa-sm fa-trophy' })); + } + nameSpan.prepend($('
', { class: 'avatar' }).append($('', { src: asset.url, alt: displayName }))); + } + + assetBlock.addClass('asset-block'); + return assetBlock; } +/** + * Builds and appends the menu section for a single asset type. + * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip' + * @returns {Promise} + */ +async function buildAssetTypeSection(assetType) { + const assetTypeMenu = $('
', { id: `assets_${assetType}_div`, class: 'assets-list-div' }); + assetTypeMenu.attr('data-type', assetType); + assetTypeMenu.append($('

').text(KNOWN_TYPES[assetType] || assetType)).hide(); + + if (assetType == 'extension') { + assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation')); + } + + for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) { + const i = availableAssets[assetType].indexOf(asset); + const element = createAssetButton(asset, assetType, i); + const assetBlock = createAssetBlock(asset, assetType, element); + + if (assetType === 'extension') { + const extensionBlockList = isOfficialExtension(asset.url) + ? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions') + : assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions'); + extensionBlockList.append(assetBlock); + } else { + assetTypeMenu.append(assetBlock); + } + } + + assetTypeMenu.appendTo('#assets_menu'); + assetTypeMenu.on('click', 'a.asset_preview', previewAsset); +} + +/** + * Parses the fetched assets JSON and renders the full assets menu. + * @param {object[]} json Array of asset objects, each containing at least id, name, description, url and type fields + */ +async function populateAssetsMenu(json) { + availableAssets = {}; + $('#assets_menu').empty(); + + console.debug(DEBUG_PREFIX, 'Received assets dictionary', json); + + for (const i of json) { + if (availableAssets[i.type] === undefined) + availableAssets[i.type] = []; + availableAssets[i.type].push(i); + } + + console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets); + // First extensions, then everything else + const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0); + + $('#assets_type_select').empty(); + $('#assets_search').val(''); + $('#assets_type_select').append($('

+
+

Official Extensions

+
+ + These extensions are maintained by the SillyTavern team. + +
+
+
+
+

Community Extensions

+
+ + Community extensions are not reviewed or verified by the SillyTavern team. Please exercise caution when installing. + +
+
+
diff --git a/public/scripts/extensions/assets/manifest.json b/public/scripts/extensions/assets/manifest.json index 39ba8af1d..f10a02a70 100644 --- a/public/scripts/extensions/assets/manifest.json +++ b/public/scripts/extensions/assets/manifest.json @@ -7,5 +7,8 @@ "css": "style.css", "author": "Keij#6799", "version": "0.1.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/assets/style.css b/public/scripts/extensions/assets/style.css index ce46ee8bc..40cc0755f 100644 --- a/public/scripts/extensions/assets/style.css +++ b/public/scripts/extensions/assets/style.css @@ -27,15 +27,20 @@ margin-bottom: 0.25em; } +.assets-list-div h2 { + margin: 0; + font-size: 1.1em; +} + .assets-list-div h3 { text-transform: capitalize; } -.assets-list-div i a { +.assets-list-div .asset-block a { color: inherit; } -.assets-list-div>i { +.assets-list-div .asset-block { display: flex; flex-direction: row; align-items: center; @@ -46,7 +51,7 @@ border-bottom: 1px solid var(--SmartThemeBorderColor); } -.assets-list-div i span:first-of-type { +.assets-list-div .asset-block span:first-of-type { font-weight: bold; } @@ -198,3 +203,11 @@ .asset-name>b { font-weight: 600; } + +div:is(.assets-list-extensions-official, .assets-list-extensions-community):has(.assets-list-extensions:empty) { + display: none; +} + +div:is(.assets-list-extensions-official, .assets-list-extensions-community) { + margin-top: 10px; +} diff --git a/public/scripts/extensions/attachments/index.js b/public/scripts/extensions/attachments/index.js index 0583d4d9e..31e858fd7 100644 --- a/public/scripts/extensions/attachments/index.js +++ b/public/scripts/extensions/attachments/index.js @@ -243,7 +243,7 @@ function handleCharacterRename(oldAvatar, newAvatar) { } } -jQuery(async () => { +export async function init() { eventSource.on(event_types.APP_READY, cleanUpAttachments); eventSource.on(event_types.CHARACTER_DELETED, cleanUpCharacterAttachments); eventSource.on(event_types.CHARACTER_RENAMED, handleCharacterRename); @@ -407,4 +407,4 @@ jQuery(async () => { }), ], })); -}); +} diff --git a/public/scripts/extensions/attachments/manifest.json b/public/scripts/extensions/attachments/manifest.json index 27f55f77c..0b2fdf027 100644 --- a/public/scripts/extensions/attachments/manifest.json +++ b/public/scripts/extensions/attachments/manifest.json @@ -7,5 +7,8 @@ "css": "style.css", "author": "Cohee1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/caption/index.js b/public/scripts/extensions/caption/index.js index d10014b0c..73cd394f6 100644 --- a/public/scripts/extensions/caption/index.js +++ b/public/scripts/extensions/caption/index.js @@ -1,8 +1,9 @@ import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js'; import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js'; -import { appendMediaToMessage, chat_metadata, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js'; +import { appendMediaToMessage, chat_metadata, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParams } from '../../../script.js'; import { getMessageTimeStamp } from '../../RossAscends-mods.js'; import { SECRET_KEYS, secret_state } from '../../secrets.js'; +import { oai_settings } from '../../openai.js'; import { getMultimodalCaption } from '../shared.js'; import { textgen_types, textgenerationwebui_settings } from '../../textgen-settings.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; @@ -99,7 +100,7 @@ async function wrapCaptionTemplate(caption) { template += ' {{caption}}'; } - let messageText = substituteParamsExtended(template, { caption: caption }); + let messageText = substituteParams(template, { dynamicMacros: { caption: caption } }); if (extension_settings.caption.refine_mode) { messageText = await Popup.show.input( @@ -321,6 +322,8 @@ async function captionMultimodal(base64Img, externalPrompt) { prompt = String(customPrompt).trim(); } + prompt = substituteParams(prompt); + const caption = await getMultimodalCaption(base64Img, prompt); return { caption }; } @@ -454,7 +457,7 @@ function isVideoCaptioningAvailable() { return ['google', 'vertexai', 'zai'].includes(extension_settings.caption.multimodal_api); } -jQuery(async function () { +export async function init() { function addSendPictureButton() { const sendButton = $(`
@@ -504,6 +507,7 @@ jQuery(async function () { 'chutes': SECRET_KEYS.CHUTES, 'electronhub': SECRET_KEYS.ELECTRONHUB, 'pollinations': SECRET_KEYS.POLLINATIONS, + 'workers_ai': SECRET_KEYS.WORKERS_AI, }; if (chatCompletionApis[api] && secret_state[chatCompletionApis[api]]) { @@ -580,7 +584,7 @@ jQuery(async function () { } async function addRemoteEndpointModels() { - async function processEndpoint(api, url) { + async function processEndpoint(api, url, additionalParams = {}) { const dropdown = document.getElementById('caption_multimodal_model'); if (!(dropdown instanceof HTMLSelectElement)) { return; @@ -591,7 +595,8 @@ jQuery(async function () { const options = Array.from(dropdown.options); const response = await fetch(url, { method: 'POST', - headers: getRequestHeaders({ omitContentType: true }), + headers: getRequestHeaders(), + body: JSON.stringify(additionalParams), }); if (!response.ok) { return; @@ -620,6 +625,7 @@ jQuery(async function () { await processEndpoint('mistral', '/api/backends/chat-completions/multimodal-models/mistral'); await processEndpoint('xai', '/api/backends/chat-completions/multimodal-models/xai'); await processEndpoint('moonshot', '/api/backends/chat-completions/multimodal-models/moonshot'); + await processEndpoint('workers_ai', '/api/backends/chat-completions/multimodal-models/workers_ai', { workers_ai_account_id: oai_settings.workers_ai_account_id }); } await addSettings(); @@ -804,4 +810,4 @@ jQuery(async function () { })); document.body.classList.add('caption'); -}); +} diff --git a/public/scripts/extensions/caption/manifest.json b/public/scripts/extensions/caption/manifest.json index 56b03acb0..f5099a01c 100644 --- a/public/scripts/extensions/caption/manifest.json +++ b/public/scripts/extensions/caption/manifest.json @@ -9,5 +9,8 @@ "css": "style.css", "author": "Cohee#1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/caption/settings.html b/public/scripts/extensions/caption/settings.html index fe0c2a3c6..011759a12 100644 --- a/public/scripts/extensions/caption/settings.html +++ b/public/scripts/extensions/caption/settings.html @@ -20,6 +20,7 @@ + @@ -53,7 +54,14 @@ + + + + + + + @@ -88,6 +96,7 @@ + @@ -144,6 +153,11 @@ + + + + + @@ -174,6 +188,7 @@ + diff --git a/public/scripts/extensions/connection-manager/index.js b/public/scripts/extensions/connection-manager/index.js index e809677ee..6a4c2b0a0 100644 --- a/public/scripts/extensions/connection-manager/index.js +++ b/public/scripts/extensions/connection-manager/index.js @@ -1,7 +1,7 @@ import { DOMPurify, Fuse } from '../../../lib.js'; -import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js'; -import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; +import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js'; +import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js'; import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js'; @@ -9,11 +9,16 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from ' import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js'; import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; +import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js'; -import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js'; +import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js'; import { t } from '../../i18n.js'; import { getSecretLabelById } from '../../secrets.js'; +import { performFuzzySearch } from '/scripts/power-user.js'; +import { StreamingDisplay } from '/scripts/streaming-display.js'; +import { ConnectionManagerRequestService } from '../shared.js'; +import { formatReasoning } from '/scripts/reasoning.js'; const MODULE_NAME = 'connection-manager'; const NONE = ''; @@ -474,7 +479,223 @@ async function renderDetailsContent(detailsContent) { } } -(async function () { +/** + * Callback for the /profile-genstream command + * Generates text using Connection Manager with streaming display support. + * @param {object} args Named arguments + * @param {string} value Unnamed argument (the prompt) + * @returns {Promise} The generated text, optionally with formatted reasoning + */ +async function generateStreamCallback(args, value) { + if (!value) { + console.warn('WARN: No argument provided for /profile-genstream command'); + return ''; + } + + // Check if Connection Manager is available + const context = getContext(); + if (context.extensionSettings.disabledExtensions.includes('connection-manager')) { + toastr.error(t`Connection Manager is required for /profile-genstream. Use /gen or /genraw instead.`); + return ''; + } + + const profileIdOrName = args?.profile; + const includeReasoning = isTrueBoolean(args?.reasoning); + const systemPrompt = typeof args?.system == 'string' ? args.system : ''; + const maxTokens = Number(args?.length ?? 2048) || 2048; + const lock = isTrueBoolean(args?.lock); + const generatingLabel = typeof args?.generating === 'string' ? args.generating : 'Generating...'; + const completedLabel = typeof args?.completed === 'string' ? args.completed : 'Generated'; + const enableStop = !isFalseBoolean(args?.stop); + const onStopClosure = args?.onStop instanceof SlashCommandClosure ? args.onStop : null; + const onCompleteClosure = args?.onComplete instanceof SlashCommandClosure ? args.onComplete : null; + + // Parse delay: 'infinite' or negative = null (stay open), number = delay in ms + let completeDelay = 3000; // Default 3 seconds + if (args?.delay !== undefined) { + if (typeof args.delay === 'string' && args.delay.toLowerCase() === 'infinite') { + completeDelay = null; // Stay until user closes + } else { + const parsed = Number(args.delay); + if (!isNaN(parsed) && parsed >= 0) { + completeDelay = parsed; + } else if (!isNaN(parsed) && parsed < 0) { + completeDelay = null; // Negative = infinite + } + } + } + + // Create abort controller for stop functionality (when stop is enabled) + const abortController = enableStop ? new AbortController() : null; + + // Compose the stop handler: abort the request + optionally invoke user closure + const onStopHandler = enableStop ? async () => { + abortController.abort(); + if (onStopClosure) { + try { + const localClosure = onStopClosure.getCopy(); + localClosure.onProgress = () => { }; + await localClosure.execute(); + } catch (e) { + console.error('[GenStream] Error executing onStop closure', e); + } + } + } : null; + + try { + if (lock) { + deactivateSendButtons(); + } + + // Determine which profile to use + // Use the currently selected profile if no profile specified + let effectiveProfileId = context.extensionSettings.connectionManager.selectedProfile; + + const profiles = context.extensionSettings.connectionManager.profiles; + + if (profileIdOrName) { + // Use try to find profile by id first, then fuse search + const profile = profiles.find(p => p.id === profileIdOrName); + if (profile) { + effectiveProfileId = profile.id; + } else { + const keys = [ + { name: 'name', weight: 10 }, + ]; + const fuseResults = performFuzzySearch('profile', profiles, keys, profileIdOrName); + if (fuseResults.length > 0) { + effectiveProfileId = fuseResults[0].item.id; + } else { + toastr.warning(t`Connection profile not found: ${profileIdOrName}`); + return ''; + } + } + } + + if (!effectiveProfileId) { + toastr.error(t`No connection profile specified or selected. Use profile= argument or select a profile in Connection Manager.`); + return ''; + } + + // Create streaming display + const display = new StreamingDisplay(); + display.show({ + label: generatingLabel, + icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId), + onStop: onStopHandler, + }); + + const messages = [ + ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []), + { role: 'user', content: value }, + ]; + + let finalText = ''; + let finalReasoning = ''; + + /** Gets the final (if requested, formatted) text to return for this command @returns {string} */ + function buildResultText() { + // Format output with reasoning if requested + if (includeReasoning && finalReasoning) { + const { formatted } = formatReasoning(finalReasoning, finalText); + return formatted; + } + + return finalText; + } + + try { + // Attempt streaming first + const streamResponse = await ConnectionManagerRequestService.sendRequest( + effectiveProfileId, + messages, + maxTokens, + { extractData: true, includePreset: true, stream: true, signal: abortController?.signal ?? undefined }, + ); + + if (typeof streamResponse === 'function') { + const generator = streamResponse(); + for await (const chunk of generator) { + finalText = chunk.text; + finalReasoning = chunk.state?.reasoning || ''; + display.updateReasoning(finalReasoning); + display.updateContent(finalText); + } + } else { + // Non-streaming fallback within the try block + const extracted = streamResponse; + finalText = extracted?.content || ''; + finalReasoning = extracted?.reasoning || ''; + if (finalReasoning) { + display.updateReasoning(finalReasoning); + } + display.updateContent(finalText); + } + } catch (error) { + // If the user clicked stop, don't retry — show stopped state and return empty + if (abortController?.signal?.aborted) { + display.markStopped({ label: `${generatingLabel} [Stopped]` }); + return buildResultText(); + } + + console.warn('[Slash Commands] Streaming failed, falling back to non-streaming:', error); + display.hide({ instant: true }); + + // Retry with non-streaming + const response = await ConnectionManagerRequestService.sendRequest( + effectiveProfileId, + messages, + maxTokens, + { extractData: true, includePreset: true, stream: false }, + ); + + const extracted = /** @type {import('../../custom-request.js').ExtractedData} */ (response); + finalText = extracted?.content || ''; + finalReasoning = extracted?.reasoning || ''; + + // Show quick non-streaming display + display.show({ + label: generatingLabel, + icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId), + }); + if (finalReasoning) { + display.updateReasoning(finalReasoning); + } + display.updateContent(finalText); + } + + // Mark as complete with delay (null = stay open until user closes) + display.complete({ label: completedLabel, delay: completeDelay }); + + // Invoke onComplete closure if provided + if (onCompleteClosure) { + try { + const localClosure = onCompleteClosure.getCopy(); + localClosure.onProgress = () => { }; + await localClosure.execute(); + } catch (e) { + console.error('[GenStream] Error executing onComplete closure', e); + } + } + + if (!finalText) { + toastr.warning(t`Generation returned empty result`); + return ''; + } + + return buildResultText(); + } catch (err) { + console.error('Error on /genstream generation', err); + toastr.error(err.message, t`API Error`, { preventDuplicates: true }); + return ''; + } finally { + if (lock) { + activateSendButtons(); + } + } +} + +export async function init() { extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS); for (const key of Object.keys(DEFAULT_SETTINGS)) { @@ -824,4 +1045,114 @@ async function renderDetailsContent(detailsContent) { return JSON.stringify(profile); }, })); -})(); + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'profile-genstream', + callback: generateStreamCallback, + returns: t`generated text`, + namedArgumentList: [ + new SlashCommandNamedArgument( + 'lock', t`lock user input during generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(), + ), + SlashCommandNamedArgument.fromProps({ + name: 'profile', + description: t`connection profile ID to use for generation`, + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: commonEnumProviders.connectionProfiles(), + }), + SlashCommandNamedArgument.fromProps({ + name: 'reasoning', + description: t`include formatted reasoning in the output`, + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'false', + enumProvider: commonEnumProviders.boolean('trueFalse'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'system', + description: t`system prompt at the start`, + typeList: [ARGUMENT_TYPE.STRING], + }), + SlashCommandNamedArgument.fromProps({ + name: 'length', + description: t`API response length in tokens`, + typeList: [ARGUMENT_TYPE.NUMBER], + defaultValue: '2048', + }), + SlashCommandNamedArgument.fromProps({ + name: 'generating', + description: t`label/title for the generation display`, + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: 'Generating...', + }), + SlashCommandNamedArgument.fromProps({ + name: 'completed', + description: t`updated label/title for when generation completes`, + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: 'Generated', + }), + SlashCommandNamedArgument.fromProps({ + name: 'delay', + description: t`auto-hide delay in ms after generation completes. Use "infinite" or negative to keep until manually closed`, + typeList: [ARGUMENT_TYPE.NUMBER], + defaultValue: '3000', + enumList: [ + new SlashCommandEnumValue('infinite', 'Keep the streaming display open until manually closed', 'command', '♾️'), + new SlashCommandEnumValue('any delay in seconds', null, 'number', '⌚', () => true, input => input), + ], + }), + SlashCommandNamedArgument.fromProps({ + name: 'stop', + description: t`show a stop button on the streaming display that aborts generation when clicked`, + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'true', + enumProvider: commonEnumProviders.boolean('trueFalse'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'onStop', + description: t`closure to execute when the stop button is clicked (in addition to aborting the request)`, + typeList: [ARGUMENT_TYPE.CLOSURE], + }), + SlashCommandNamedArgument.fromProps({ + name: 'onComplete', + description: t`closure to execute after generation completes successfully`, + typeList: [ARGUMENT_TYPE.CLOSURE], + }), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'prompt', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + }), + ], + helpString: ` +
+ ${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`} +
+
+ ${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`} +
+
+ ${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`} +
+
+ ${t`Use delay to control auto-hide behavior: number (ms), "infinite", or negative to keep the display open until manually closed. The display shows a green LED when complete.`} +
+
+ ${t`A stop button is shown by default (stop=true). Click it to abort generation and return whatever was streamed so far. Use stop=false to hide the stop button.`} +
+
+ ${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`} +
+
+ ${t`Example:
/profile-genstream profile=my-profile-id reasoning=true Summarize the following text
`} +
+
+ ${t`Example with infinite display:
/profile-genstream delay=infinite Tell me a story
`} +
+
+ ${t`Example with custom stop handler:
/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story
`} +
+ `, + })); +} diff --git a/public/scripts/extensions/connection-manager/manifest.json b/public/scripts/extensions/connection-manager/manifest.json index 601f8970c..0bbffc0d4 100644 --- a/public/scripts/extensions/connection-manager/manifest.json +++ b/public/scripts/extensions/connection-manager/manifest.json @@ -7,5 +7,8 @@ "css": "style.css", "author": "Cohee1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 185bea349..3ef1ecf8f 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -4,7 +4,7 @@ import { characters, eventSource, event_types, generateQuietPrompt, generateRaw, import { dragElement, isMobile } from '../../RossAscends-mods.js'; import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; -import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean } from '../../utils.js'; +import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean, includesIgnoreCaseAndAccents } from '../../utils.js'; import { hideMutedSprites, selected_group } from '../../group-chats.js'; import { isJsonSchemaSupported } from '../../textgen-settings.js'; import { debounce_timeout } from '../../constants.js'; @@ -786,6 +786,32 @@ async function setSpriteSlashCommand({ type }, searchTerm) { return label; } +/** + * @param {string} expressionName - Label of the expression to set as fallback + */ +function setFallBackExpressionSlashCommand(args, expressionName) { + expressionName = expressionName.trim().toLowerCase(); + + if (!expressionName) return extension_settings?.expressions?.fallback_expression || ''; + + const select = /** @type {HTMLSelectElement} */(document.getElementById('expression_fallback')); + const fallbackExpressions = Array + .from(select?.options || []) + .map(option => option.value) + .filter(expression => expression?.length > 0); + + const expressionMatch = fallbackExpressions.find(expression => includesIgnoreCaseAndAccents(expression, expressionName)); + + if (!expressionMatch) { + toastr.warning(t`No expression found for search term ${expressionName}`, t`Set Fallback Expression`); + return ''; + } + + $(select).val(expressionMatch).trigger('change'); + + return expressionMatch; +} + /** * Returns the sprite folder name (including override) for a character. * @param {object} char Character object @@ -2140,7 +2166,7 @@ function migrateSettings() { } } -(async function () { +export async function init() { function addExpressionImage() { const html = `
@@ -2316,6 +2342,42 @@ function migrateSettings() { helpString: 'Force sets the expression for the current character.', returns: 'The currently set expression label after setting it.', })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'expression-fallback', + callback: setFallBackExpressionSlashCommand, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'expression label to set', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + enumProvider: () => [ + new SlashCommandEnumValue('#none', 'Sets the fallback expression to no image'), + new SlashCommandEnumValue('#emoji', 'Sets the fallback expression to emojis'), + ...localEnumProviders.expressions(), + ], + }), + ], + helpString: ` +
+ Gets the currently selected expression fallback for all characters.
+ If a valid expression label is sent, it will be set as the new fallback. +
+
+ Example: +
    +
  • +
    /expression-fallback | /echo
    + Returns the currently selected fallback. +
  • +
  • +
    /expression-fallback admiration
    + Sets a new expression as fallback. +
  • +
+
+ `, + returns: 'The currently set expression label after setting it.', + })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'expression-folder-override', aliases: ['spriteoverride', 'costume'], @@ -2511,4 +2573,4 @@ function migrateSettings() {
`, })); -})(); +} diff --git a/public/scripts/extensions/expressions/manifest.json b/public/scripts/extensions/expressions/manifest.json index 2c8076e45..d063c427f 100644 --- a/public/scripts/extensions/expressions/manifest.json +++ b/public/scripts/extensions/expressions/manifest.json @@ -9,5 +9,8 @@ "css": "style.css", "author": "Cohee#1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/gallery/index.js b/public/scripts/extensions/gallery/index.js index ecd20d088..6a581cbcb 100644 --- a/public/scripts/extensions/gallery/index.js +++ b/public/scripts/extensions/gallery/index.js @@ -818,7 +818,7 @@ function addGalleryWandButton() { } // On extension load, ensure the settings are initialized -(function () { +export async function init() { initSettings(); eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => { const context = SillyTavern.getContext(); @@ -850,4 +850,4 @@ function addGalleryWandButton() { }), ); addGalleryWandButton(); -})(); +} diff --git a/public/scripts/extensions/gallery/manifest.json b/public/scripts/extensions/gallery/manifest.json index 0ba46c135..8fcf0e83b 100644 --- a/public/scripts/extensions/gallery/manifest.json +++ b/public/scripts/extensions/gallery/manifest.json @@ -8,5 +8,8 @@ "css": "style.css", "author": "City-Unit", "version": "1.5.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/memory/index.js b/public/scripts/extensions/memory/index.js index e9d3da624..af268ba58 100644 --- a/public/scripts/extensions/memory/index.js +++ b/public/scripts/extensions/memory/index.js @@ -1063,7 +1063,7 @@ function setupListeners() { }); } -jQuery(async function () { +export async function init() { async function addExtensionControls() { const settingsHtml = await renderExtensionTemplateAsync('memory', 'settings', { defaultSettings }); $('#summarize_container').append(settingsHtml); @@ -1128,4 +1128,4 @@ jQuery(async function () { () => summaryMacroHandler(), 'Returns the latest memory/summary from the current chat.'); } -}); +} diff --git a/public/scripts/extensions/memory/manifest.json b/public/scripts/extensions/memory/manifest.json index 76deda81e..791e43ffe 100644 --- a/public/scripts/extensions/memory/manifest.json +++ b/public/scripts/extensions/memory/manifest.json @@ -9,5 +9,8 @@ "css": "style.css", "author": "Cohee#1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/quick-reply/index.js b/public/scripts/extensions/quick-reply/index.js index 42d5f2289..fd699945b 100644 --- a/public/scripts/extensions/quick-reply/index.js +++ b/public/scripts/extensions/quick-reply/index.js @@ -169,7 +169,7 @@ const handleCharChange = () => { settings.charConfig = charConfig; }; -const init = async () => { +export async function init() { await loadSets(); await loadSettings(); log('settings: ', settings); @@ -214,7 +214,8 @@ const init = async () => { eventSource.on(event_types.APP_READY, async () => await finalizeInit()); globalThis.quickReplyApi = quickReplyApi; -}; +} + const finalizeInit = async () => { debug('executing startup'); await autoExec.handleStartup(); @@ -229,7 +230,7 @@ const finalizeInit = async () => { isReady = true; debug('READY'); }; -await init(); + const purgeCharacterQuickReplySets = ({ character }) => { // Remove the character's Quick Reply Sets from the settings. diff --git a/public/scripts/extensions/quick-reply/manifest.json b/public/scripts/extensions/quick-reply/manifest.json index 4c773fe11..31cdff787 100644 --- a/public/scripts/extensions/quick-reply/manifest.json +++ b/public/scripts/extensions/quick-reply/manifest.json @@ -7,5 +7,8 @@ "css": "style.css", "author": "RossAscends#1779", "version": "2.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/regex/index.js b/public/scripts/extensions/regex/index.js index 60f8e71ba..ca51b606c 100644 --- a/public/scripts/extensions/regex/index.js +++ b/public/scripts/extensions/regex/index.js @@ -1639,7 +1639,7 @@ async function checkCharEmbeddedRegexScripts() { function notifyReloadCurrentChat(presetName) { toastr.info( t`Reload the chat for regex to take effect` + '
' + t`Click here to reload immediately` + '', - t`Preset '${presetName}' contains enabled regex scripts`, + t`Preset '${escapeHtml(presetName)}' contains enabled regex scripts`, { timeOut: 5000, escapeHtml: false, @@ -1709,7 +1709,7 @@ function onPresetRenamed({ apiId, oldName, newName }) { // 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 () => { +export async function init() { if (!Array.isArray(extension_settings.regex)) { extension_settings.regex = []; } @@ -2068,6 +2068,37 @@ jQuery(async () => { ], helpString: 'Runs a Regex extension script by name on the provided string. The script must be enabled.', })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'regex-state', + /** @param {object} _ @param {string} name */ + callback: (_, name) => { + if (!name) { + toastr.warning('No regex script name provided.'); + return ''; + } + + const scripts = getRegexScripts(); + const script = scripts.find(s => equalsIgnoreCaseAndAccents(s.scriptName, name)); + + if (!script) { + toastr.warning(`Regex script "${name}" not found.`); + return ''; + } + + return script.disabled ? 'false' : 'true'; + }, + returns: 'true (for enabled) or false (for disabled)', + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'script name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: localEnumProviders.regexScripts, + }), + ], + helpString: 'Returns the current state of a regex script.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'regex-toggle', callback: toggleRegexCallback, @@ -2123,4 +2154,4 @@ jQuery(async () => { presetManager.setupEventListeners(); presetManager.registerSlashCommands(); -}); +} diff --git a/public/scripts/extensions/regex/manifest.json b/public/scripts/extensions/regex/manifest.json index d2e4215be..431af371c 100644 --- a/public/scripts/extensions/regex/manifest.json +++ b/public/scripts/extensions/regex/manifest.json @@ -7,5 +7,8 @@ "css": "style.css", "author": "kingbri", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/shared.js b/public/scripts/extensions/shared.js index 420495161..81224859e 100644 --- a/public/scripts/extensions/shared.js +++ b/public/scripts/extensions/shared.js @@ -1,4 +1,4 @@ -import { CONNECT_API_MAP, getRequestHeaders } from '../../script.js'; +import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js'; import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js'; import { t } from '../i18n.js'; import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js'; @@ -126,6 +126,10 @@ export async function getMultimodalCaption(base64Img, prompt) { requestBody.zai_endpoint = oai_settings.zai_endpoint || ZAI_ENDPOINT.COMMON; } + if (extension_settings.caption.multimodal_api === 'workers_ai') { + requestBody.workers_ai_account_id = oai_settings.workers_ai_account_id; + } + function getEndpointUrl() { switch (extension_settings.caption.multimodal_api) { case 'google': @@ -283,6 +287,10 @@ function throwIfInvalidModel(useReverseProxy) { if (multimodalApi === 'pollinations' && !secret_state[SECRET_KEYS.POLLINATIONS]) { throw new Error('Pollinations API key is not set.'); } + + if (multimodalApi === 'workers_ai' && (!secret_state[SECRET_KEYS.WORKERS_AI] || !oai_settings.workers_ai_account_id)) { + throw new Error('Workers AI API key or account ID is not set.'); + } } /** @@ -435,10 +443,12 @@ export class ConnectionManagerRequestService { max_tokens: maxTokens, model: profile.model, chat_completion_source: selectedApiMap.source, + secret_id: profile['secret-id'], custom_url: profile['api-url'], vertexai_region: profile['api-url'], zai_endpoint: profile['api-url'], siliconflow_endpoint: profile['api-url'], + minimax_endpoint: profile['api-url'], reverse_proxy: proxyPreset?.url, proxy_password: proxyPreset?.password, custom_prompt_post_processing: profile['prompt-post-processing'], @@ -459,6 +469,7 @@ export class ConnectionManagerRequestService { model: profile.model, api_type: selectedApiMap.type, api_server: profile['api-url'], + secret_id: profile['secret-id'], ...overridePayload, }, { instructName: includeInstruct ? profile.instruct : undefined, @@ -533,6 +544,29 @@ export class ConnectionManagerRequestService { return profile; } + /** + * Creates a model icon Image element for the given profile (or the currently selected profile). + * Returns null if the profile is not found, has no API, or Connection Manager is unavailable. + * @param {string} [profileId] - Profile ID. If omitted, uses the currently selected profile. + * @returns {HTMLImageElement | null} + */ + static getProfileIcon(profileId) { + if ((SillyTavern.getContext()).extensionSettings.disabledExtensions.includes('connection-manager')) { + return null; + } + + const id = profileId ?? (SillyTavern.getContext()).extensionSettings.connectionManager.selectedProfile; + if (!id) return null; + + try { + const profile = this.getProfile(id); + if (!profile?.api) return null; + return createModelIcon(profile.api, profile.model); + } catch { + return null; + } + } + /** * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile] * @returns {boolean} diff --git a/public/scripts/extensions/stable-diffusion/button.html b/public/scripts/extensions/stable-diffusion/button.html index 2962ff4f4..c16cb15b7 100644 --- a/public/scripts/extensions/stable-diffusion/button.html +++ b/public/scripts/extensions/stable-diffusion/button.html @@ -2,7 +2,3 @@
Generate Image
-
-
- Stop Image Generation -
diff --git a/public/scripts/extensions/stable-diffusion/index.js b/public/scripts/extensions/stable-diffusion/index.js index 412226463..c9e947db4 100644 --- a/public/scripts/extensions/stable-diffusion/index.js +++ b/public/scripts/extensions/stable-diffusion/index.js @@ -62,18 +62,13 @@ import { t, translate } from '../../i18n.js'; import { oai_settings } from '../../openai.js'; import { power_user } from '/scripts/power-user.js'; import { MacrosParser } from '/scripts/macros.js'; +import { ActionLoaderHandle, loader } from '/scripts/action-loader.js'; export { MODULE_NAME }; const MODULE_NAME = 'sd'; // This is a 1x1 transparent PNG const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; -const CUSTOM_STOP_EVENT = 'sd_stop_generation'; - -// Generation tracking for status indicator -let activeGenerations = 0; -/** @type {JQuery|null} */ -let generationToast = null; const sources = { extras: 'extras', @@ -99,6 +94,7 @@ const sources = { google: 'google', zai: 'zai', openrouter: 'openrouter', + workersai: 'workersai', }; const comfyTypes = { standard: 'standard', @@ -1747,6 +1743,9 @@ async function loadSamplers() { case sources.openrouter: samplers = ['N/A']; break; + case sources.workersai: + samplers = ['N/A']; + break; } for (const sampler of samplers) { @@ -1815,6 +1814,34 @@ async function loadAutoSamplers() { } } +async function loadSdcppModels() { + if (!extension_settings.sd.sdcpp_url) { + return [{ value: '', text: 'N/A' }]; + } + + try { + const result = await fetch('/api/sd/sdcpp/models', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ url: extension_settings.sd.sdcpp_url }), + }); + + if (!result.ok) { + return [{ value: '', text: 'N/A' }]; + } + + const data = await result.json(); + + if (data?.data?.length > 0) { + return data.data.map(model => ({ value: model.id, text: model.name || model.id })); + } + } catch (error) { + console.error('Failed to load sd.cpp models:', error); + } + + return [{ value: '', text: 'N/A' }]; +} + async function loadSdcppSamplers() { // The sdcpp server does not provide an API for samplers, so we return the known list. return ['euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd']; @@ -1910,7 +1937,7 @@ async function loadModels() { models = await loadAutoModels(); break; case sources.sdcpp: - models = [{ value: '', text: 'N/A' }]; + models = await loadSdcppModels(); break; case sources.drawthings: models = await loadDrawthingsModels(); @@ -1969,6 +1996,9 @@ async function loadModels() { case sources.openrouter: models = await loadOpenRouterModels(); break; + case sources.workersai: + models = await loadWorkersAIImageModels(); + break; } if (extension_settings.sd.source === sources.electronhub) { @@ -2096,6 +2126,33 @@ async function loadXAIModels() { ]; } +async function loadWorkersAIImageModels() { + $('#sd_cf_workers_key').toggleClass('success', !!secret_state[SECRET_KEYS.WORKERS_AI]); + + if (!secret_state[SECRET_KEYS.WORKERS_AI]) { + return []; + } + + if (!oai_settings.workers_ai_account_id) { + toastr.warning('Workers AI account ID is required. Save it in the "API Connections" panel.', 'Image Generation'); + return []; + } + + const result = await fetch('/api/sd/workersai/models', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + account_id: oai_settings.workers_ai_account_id, + }), + }); + + if (result.ok) { + return await result.json(); + } + + return []; +} + async function loadPollinationsModels() { $('#sd_pollinations_key').toggleClass('success', !!secret_state[SECRET_KEYS.POLLINATIONS]); @@ -2325,6 +2382,8 @@ async function loadDrawthingsModels() { async function loadOpenAiModels() { return [ + { value: 'gpt-image-2', text: 'gpt-image-2' }, + { value: 'gpt-image-2-2026-04-21', text: 'gpt-image-2-2026-04-21' }, { value: 'gpt-image-1.5', text: 'gpt-image-1.5' }, { value: 'gpt-image-1-mini', text: 'gpt-image-1-mini' }, { value: 'gpt-image-1', text: 'gpt-image-1' }, @@ -2581,6 +2640,9 @@ async function loadSchedulers() { case sources.openrouter: schedulers = ['N/A']; break; + case sources.workersai: + schedulers = ['N/A']; + break; } for (const scheduler of schedulers) { @@ -2701,6 +2763,9 @@ async function loadVaes() { case sources.openrouter: vaes = ['N/A']; break; + case sources.workersai: + vaes = ['N/A']; + break; } for (const vae of vaes) { @@ -2912,62 +2977,6 @@ function ensureSelectionExists(setting, selector) { } } -/** - * Updates the generation status indicator based on active generation count. - * Shows/hides various UI indicators to inform user of background image generation. - */ -function updateGenerationIndicator() { - if (activeGenerations > 0) { - const countText = activeGenerations > 1 ? ` (${activeGenerations})` : ''; - const toastText = ` ${t`Generating an image`}${countText}...`; - - // Show persistent toast if not already showing - if (!generationToast) { - generationToast = toastr.info( - toastText, - 'Image Generation', - { - timeOut: 0, - extendedTimeOut: 0, - tapToDismiss: true, - escapeHtml: false, - onHidden: () => { - generationToast = null; - }, - }, - ); - } else if (activeGenerations > 1) { - // Update count in existing toast - const toastMessage = $(generationToast).find('.toast-message'); - if (toastMessage.length) { - toastMessage.html(toastText); - } - } - } else { - // Hide toast when done - if (generationToast) { - toastr.clear(generationToast); - generationToast = null; - } - } -} - -/** - * Increments the active generation counter and updates indicators. - */ -function startGenerationTracking() { - activeGenerations++; - updateGenerationIndicator(); -} - -/** - * Decrements the active generation counter and updates indicators. - */ -function endGenerationTracking() { - activeGenerations = Math.max(0, activeGenerations - 1); - updateGenerationIndicator(); -} - /** * Generates an image based on the given trigger word. * @param {string} initiator The initiator of the image generation @@ -3028,12 +3037,13 @@ async function generatePicture(initiator, args, trigger, message, callback) { const dimensions = setTypeSpecificDimensions(generationType); const abortController = new AbortController(); - const stopButton = document.getElementById('sd_stop_gen'); let negativePromptPrefix = args?.negative || ''; let imagePath = ''; const stopListener = () => abortController.abort('Aborted by user'); + let loaderHandle = ActionLoaderHandle.EMPTY; + try { const combineNegatives = (prefix) => { negativePromptPrefix = combinePrefixes(negativePromptPrefix, prefix); }; @@ -3046,16 +3056,19 @@ async function generatePicture(initiator, args, trigger, message, callback) { await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData); prompt = eventData.prompt; // Allow extensions to modify the prompt - // Track this generation for status indicator - startGenerationTracking(); - // Show stop button after prompt is ready (prompt generation uses separate abort mechanism) - $(stopButton).show(); - eventSource.once(CUSTOM_STOP_EVENT, stopListener); - if (typeof args?._abortController?.addEventListener === 'function') { args._abortController.addEventListener('abort', stopListener); } + // Show non-blocking stoppable toast for this generation + loaderHandle = loader.show({ + blocking: false, + slug: `${MODULE_NAME}-image-generation`, + title: t`Image Generation`, + message: t`Generating an image...`, + onStop: stopListener, + }); + // generate the image imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal); } catch (err) { @@ -3074,10 +3087,8 @@ async function generatePicture(initiator, args, trigger, message, callback) { toastr.error(errorText, 'Image Generation'); throw new Error(errorText); } finally { - $(stopButton).hide(); restoreOriginalDimensions(dimensions); - eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener); - endGenerationTracking(); + await loaderHandle.hide(); } return imagePath; @@ -3404,6 +3415,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP case sources.openrouter: result = await generateOpenRouterImage(prefixedPrompt, signal); break; + case sources.workersai: + result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, signal); + break; } if (!result.data) { @@ -3858,6 +3872,7 @@ async function generateAutoImage(prompt, negativePrompt, signal) { async function generateSdcppImage(prompt, negativePrompt, signal) { const payload = { url: extension_settings.sd.sdcpp_url, + model: extension_settings.sd.model || undefined, prompt: prompt, negative_prompt: negativePrompt, steps: extension_settings.sd.steps, @@ -4062,7 +4077,7 @@ async function generateOpenAiImage(prompt, signal) { const isDalle2 = /dall-e-2/.test(extension_settings.sd.model); const isDalle3 = /dall-e-3/.test(extension_settings.sd.model); - const isGptImg = /gpt-image-(1|latest)/.test(extension_settings.sd.model); + const isGptImg = /gpt-image-(1|2|latest)/.test(extension_settings.sd.model); const isSora2 = /sora-2/.test(extension_settings.sd.model); if (isDalle2 && prompt.length > dalle2PromptLimit) { @@ -4719,6 +4734,33 @@ async function generateOpenRouterImage(prompt, signal) { throw new Error(text); } +async function generateWorkersAIImage(prompt, negativePrompt, signal) { + const result = await fetch('/api/sd/workersai/generate', { + method: 'POST', + headers: getRequestHeaders(), + signal: signal, + body: JSON.stringify({ + prompt: prompt, + negative_prompt: negativePrompt, + model: extension_settings.sd.model, + width: extension_settings.sd.width, + height: extension_settings.sd.height, + steps: extension_settings.sd.steps, + scale: extension_settings.sd.scale, + seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined, + account_id: oai_settings.workers_ai_account_id, + }), + }); + + if (result.ok) { + const data = await result.json(); + return { format: data?.format, data: data?.image }; + } else { + const text = await result.text(); + throw new Error(text); + } +} + async function onComfyOpenWorkflowEditorClick() { let workflow = await (await fetch('/api/sd/comfy/workflow', { method: 'POST', @@ -5029,10 +5071,6 @@ async function addSDGenButtons() { generatePicture(initiators.wand, {}, param); } }); - - const stopGenButton = $('#sd_stop_gen'); - stopGenButton.hide(); - stopGenButton.on('click', () => eventSource.emit(CUSTOM_STOP_EVENT)); } function isValidState() { @@ -5091,6 +5129,8 @@ function isValidState() { return secret_state[SECRET_KEYS.ZAI]; case sources.openrouter: return secret_state[SECRET_KEYS.OPENROUTER]; + case sources.workersai: + return !!oai_settings.workers_ai_account_id && secret_state[SECRET_KEYS.WORKERS_AI]; default: return false; } @@ -5115,10 +5155,6 @@ async function sdMessageButton($icon, { animate } = {}) { $icon.toggleClass(classes.idle, !isBusy); $icon.toggleClass(classes.busy, isBusy); $media.toggleClass(classes.animation, isBusy); - - // Update generation counter toast - const trackingFunction = isBusy ? startGenerationTracking : endGenerationTracking; - trackingFunction(); } let $media = jQuery(); @@ -5233,7 +5269,6 @@ async function writePromptFields(characterId) { * @returns {Promise} - A promise that resolves to the newly generated media attachment, or null if generation failed or was aborted. */ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, abortController = new AbortController()) { - const stopButton = document.getElementById('sd_stop_gen'); const stopListener = () => abortController.abort('Aborted by user'); const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE; let dimensions = { width: extension_settings.sd.width, height: extension_settings.sd.height }; @@ -5247,9 +5282,9 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, source: MEDIA_SOURCE.GENERATED, }; + let loaderHandle = ActionLoaderHandle.EMPTY; + try { - $(stopButton).show(); - eventSource.once(CUSTOM_STOP_EVENT, stopListener); const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; }; const savedPrompt = mediaAttachment.title ?? message.extra.title ?? ''; const savedNegative = mediaAttachment.negative ?? message.extra.negative ?? ''; @@ -5265,6 +5300,15 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString() : context.characters[context.characterId]?.name; + // Show non-blocking stoppable toast for this generation + loaderHandle = loader.show({ + blocking: false, + slug: `${MODULE_NAME}-image-generation`, + title: t`Image Generation`, + message: t`Generating an image...`, + onStop: stopListener, + }); + onStart(); result.url = await sendGenerationRequest(generationType, prompt, refineArgs.negative, characterName, callback, initiators.swipe, abortController.signal); result.generation_type = generationType; @@ -5276,11 +5320,10 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, } } finally { onComplete(); - $(stopButton).hide(); - eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener); restoreOriginalDimensions(dimensions); extension_settings.sd.seed = extension_settings.sd.original_seed; delete extension_settings.sd.original_seed; + await loaderHandle.hide(); } if (!result.url) { @@ -5441,7 +5484,7 @@ function registerFunctionTool() { }); } -jQuery(async () => { +export async function init() { await addSDGenButtons(); const getSelectEnumProvider = (id, text) => () => Array.from(document.querySelectorAll(`#${id} > [value]`)).map(x => new SlashCommandEnumValue(x.getAttribute('value'), text ? x.textContent : null)); @@ -5850,6 +5893,9 @@ jQuery(async () => { extension_settings.sd.google_duration = Number($(this).val()); saveSettingsDebounced(); }); + $('#sd_models_refresh').on('click', async () => { + await loadModels(); + }); $('#sd_electronhub_quality').on('change', function () { extension_settings.sd.electronhub_quality = String($(this).val()); saveSettingsDebounced(); @@ -5893,6 +5939,7 @@ jQuery(async () => { [sources.aimlapi]: SECRET_KEYS.AIMLAPI, [sources.comfy]: SECRET_KEYS.COMFY_RUNPOD, [sources.pollinations]: SECRET_KEYS.POLLINATIONS, + [sources.workersai]: SECRET_KEYS.WORKERS_AI, }; const shouldReloadOptions = Object.entries(keySourceMap).some(([k, v]) => k === extension_settings.sd.source && v === key); if (!shouldReloadOptions) { @@ -5948,4 +5995,4 @@ jQuery(async () => { t`Character's negative Image Generation prompt prefix`, ); } -}); +} diff --git a/public/scripts/extensions/stable-diffusion/manifest.json b/public/scripts/extensions/stable-diffusion/manifest.json index 79223483c..1ecdc6f26 100644 --- a/public/scripts/extensions/stable-diffusion/manifest.json +++ b/public/scripts/extensions/stable-diffusion/manifest.json @@ -10,5 +10,8 @@ "css": "style.css", "author": "Cohee#1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/stable-diffusion/settings.html b/public/scripts/extensions/stable-diffusion/settings.html index ba7e4d00e..a71c4083f 100644 --- a/public/scripts/extensions/stable-diffusion/settings.html +++ b/public/scripts/extensions/stable-diffusion/settings.html @@ -40,10 +40,11 @@ Minimal response prompt processing - + @@ -123,7 +124,7 @@
- +
@@ -191,14 +192,14 @@
-
- @@ -207,7 +208,7 @@
- @@ -216,7 +217,7 @@
- @@ -226,7 +227,7 @@
- @@ -318,7 +319,7 @@
- @@ -375,6 +376,20 @@
+
+ Cloudflare Workers AI +
+ API Key + +
+
+ Hint: Account ID and API key are pulled from API connections. +
+
+
@@ -394,7 +409,7 @@
- @@ -405,37 +420,42 @@
- - + +
- +
- +
- +
- +
- +
diff --git a/public/scripts/extensions/stable-diffusion/style.css b/public/scripts/extensions/stable-diffusion/style.css index e0053f740..c193ee4ab 100644 --- a/public/scripts/extensions/stable-diffusion/style.css +++ b/public/scripts/extensions/stable-diffusion/style.css @@ -1,7 +1,3 @@ -.sd_settings label:not(.checkbox_label) { - display: block; -} - #sd_dropdown { z-index: 30000; backdrop-filter: blur(var(--SmartThemeBlurStrength)); diff --git a/public/scripts/extensions/token-counter/index.js b/public/scripts/extensions/token-counter/index.js index 721597c98..ed952867c 100644 --- a/public/scripts/extensions/token-counter/index.js +++ b/public/scripts/extensions/token-counter/index.js @@ -101,7 +101,7 @@ async function doCount() { return count; } -jQuery(() => { +export function init() { const buttonHtml = `
` + @@ -115,4 +115,4 @@ jQuery(() => { returns: 'number of tokens', helpString: 'Counts the number of tokens in the current chat.', })); -}); +} diff --git a/public/scripts/extensions/token-counter/manifest.json b/public/scripts/extensions/token-counter/manifest.json index b05dd6e31..caa0ad15d 100644 --- a/public/scripts/extensions/token-counter/manifest.json +++ b/public/scripts/extensions/token-counter/manifest.json @@ -7,5 +7,8 @@ "css": "style.css", "author": "Cohee#1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/translate/index.js b/public/scripts/extensions/translate/index.js index 58de36839..9ff1c234d 100644 --- a/public/scripts/extensions/translate/index.js +++ b/public/scripts/extensions/translate/index.js @@ -707,7 +707,7 @@ const handleMessageReasoningDelete = createEventHandler(removeReasoningDisplayTe globalThis.translate = translate; -jQuery(async () => { +export async function init() { const html = await renderExtensionTemplateAsync('translate', 'index'); const buttonHtml = await renderExtensionTemplateAsync('translate', 'buttons'); @@ -801,4 +801,4 @@ jQuery(async () => { }, returns: ARGUMENT_TYPE.STRING, })); -}); +} diff --git a/public/scripts/extensions/translate/manifest.json b/public/scripts/extensions/translate/manifest.json index b40a2aed1..9ec8c918a 100644 --- a/public/scripts/extensions/translate/manifest.json +++ b/public/scripts/extensions/translate/manifest.json @@ -7,5 +7,8 @@ "css": "style.css", "author": "Cohee#1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/tts/index.js b/public/scripts/extensions/tts/index.js index 07dbb2f57..1f213e11e 100644 --- a/public/scripts/extensions/tts/index.js +++ b/public/scripts/extensions/tts/index.js @@ -1,6 +1,7 @@ import { cancelTtsPlay, eventSource, event_types, getCurrentChatId, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js'; import { ModuleWorkerWrapper, extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js'; import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique, regexFromString } from '../../utils.js'; +import { accountStorage } from '../../util/AccountStorage.js'; import { EdgeTtsProvider } from './edge.js'; import { ElevenLabsTtsProvider } from './elevenlabs.js'; import { SileroTtsProvider } from './silerotts.js'; @@ -165,7 +166,7 @@ async function onNarrateOneMessage() { } resetTtsPlayback(); - processAndQueueTtsMessage(message, Number(id)); + processAndQueueTtsMessage(message, Number(id), { manual: true }); moduleWorker(); } @@ -186,13 +187,20 @@ async function onNarrateText(args, text) { ? voiceMap[DEFAULT_VOICE_MARKER] : voiceMap[name]; - if (!voiceMapEntry || voiceMapEntry === DISABLED_VOICE_MARKER) { + if (voiceMapEntry === DISABLED_VOICE_MARKER) { + toastr.info(`TTS voice for ${name} is disabled.`); + await initVoiceMap(false); + return; + } + + if (!voiceMapEntry) { toastr.info(`Specified voice for ${name} was not found. Check the TTS extension settings.`); + await initVoiceMap(false); return; } resetTtsPlayback(); - processAndQueueTtsMessage({ mes: text, name: name }); + processAndQueueTtsMessage({ mes: text, name: name }, null, { manual: true }); await moduleWorker(); // Return back to the chat voices @@ -245,7 +253,7 @@ function isTtsProcessing() { } /** - * @typedef {ChatMessage & { id?: number }} TtsMessage + * @typedef {ChatMessage & { id?: number, manual?: boolean, segmentText?: string, segmentType?: string }} TtsMessage */ /** @@ -253,12 +261,15 @@ function isTtsProcessing() { * (if enabled) and adds each part to the TTS job queue. * @param {ChatMessage} message - The message object to be processed. * @param {number|null} [messageId=null] - The chat message index to associate with TTS events. + * @param {object} [options={}] - Additional options for processing. + * @param {boolean} [options.manual=false] - Whether this TTS job was manually triggered (e.g., from the UI) rather than automatically from a new chat message. * @returns {void} */ -function processAndQueueTtsMessage(message, messageId = null) { +function processAndQueueTtsMessage(message, messageId = null, { manual = false } = {}) { /** @type {TtsMessage} */ const clone = structuredClone(message); clone.id = messageId ?? null; + clone.manual = manual ?? false; if (!extension_settings.tts.narrate_by_paragraphs) { ttsJobQueue.push(clone); @@ -408,9 +419,10 @@ function onAudioControlClicked() { // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful if (!audioElement.paused || isTtsProcessing()) { resetTtsPlayback(); - } else { + } else if (context?.chat?.length > 0) { // Default play behavior if not processing or playing is to play the last message. - processAndQueueTtsMessage(context.chat[context.chat.length - 1]); + const id = context.chat.length - 1; + processAndQueueTtsMessage(context.chat[id], id, { manual: true }); } updateUiAudioPlayState(); } @@ -481,8 +493,10 @@ async function processAudioJobQueue() { // TTS Control // //################// +/** @type {TtsMessage[]} */ const ttsJobQueue = []; -let currentTtsJob; // Null if nothing is currently being processed +/** @type {TtsMessage|null} */ +let currentTtsJob = null; // Null if nothing is currently being processed function completeTtsJob() { console.info(`Current TTS job for ${currentTtsJob?.name} completed.`); @@ -621,7 +635,18 @@ async function processTtsQueue() { const voiceMapEntry = voiceMap[voiceMapKey] === DEFAULT_VOICE_MARKER ? voiceMap[DEFAULT_VOICE_MARKER] : voiceMap[voiceMapKey]; - if (!voiceMapEntry || voiceMapEntry === DISABLED_VOICE_MARKER) { + if (voiceMapEntry === DISABLED_VOICE_MARKER) { + const storageKey = `tts_disabled_warned_${char}`; + if (!accountStorage.getItem(storageKey) || currentTtsJob.manual) { + accountStorage.setItem(storageKey, 'true'); + toastr.info(`TTS voice for ${char} is disabled.`); + } + currentTtsJob = null; + setTimeout(() => wrapper.update(), 0); + return; + } + + if (!voiceMapEntry) { throw `${char} not in voicemap. Configure character in extension settings voice map`; } @@ -649,7 +674,6 @@ async function processTtsQueue() { text = substituteParams(text); if (extension_settings.tts.skip_codeblocks) { - text = text.replace(/^\s{4}.*$/gm, '').trim(); text = text.replace(/```.*?```/gs, '').trim(); text = text.replace(/~~~.*?~~~/gs, '').trim(); } @@ -724,6 +748,7 @@ async function processTtsQueue() { mes: currentTtsJob.mes, extra: currentTtsJob.extra, id: currentTtsJob.id, + manual: currentTtsJob.manual, }; ttsJobQueue.unshift(segmentJob); } @@ -824,7 +849,7 @@ async function playFullConversation() { context.chat.forEach((msg, i) => { if (!msg.is_system && msg.mes !== '...' && msg.mes !== '') { - processAndQueueTtsMessage(msg, i); + processAndQueueTtsMessage(msg, i, { manual: false }); } }); @@ -1164,7 +1189,7 @@ async function onMessageEvent(messageId, lastCharIndex) { message.id = messageId; ttsJobQueue.push(message); } else { - processAndQueueTtsMessage(message, messageId); + processAndQueueTtsMessage(message, messageId, { manual: false }); } } @@ -1506,7 +1531,7 @@ async function initVoiceMapInternal(unrestricted) { updateVoiceMap(); } -jQuery(async function () { +export async function init() { async function addExtensionControls() { const settingsHtml = $(await renderExtensionTemplateAsync('tts', 'settings')); $('#tts_container').append(settingsHtml); @@ -1594,4 +1619,4 @@ jQuery(async function () { })); document.body.appendChild(audioElement); -}); +} diff --git a/public/scripts/extensions/tts/manifest.json b/public/scripts/extensions/tts/manifest.json index 2f8afd419..509d3a54b 100644 --- a/public/scripts/extensions/tts/manifest.json +++ b/public/scripts/extensions/tts/manifest.json @@ -11,5 +11,8 @@ "css": "style.css", "author": "Ouoertheo#7264", "version": "1.0.0", - "homePage": "None" + "homePage": "None", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/vectors/index.js b/public/scripts/extensions/vectors/index.js index f60425888..e671234c5 100644 --- a/public/scripts/extensions/vectors/index.js +++ b/public/scripts/extensions/vectors/index.js @@ -44,6 +44,7 @@ import { oai_settings } from '../../openai.js'; * @property {string} text - The hashed message text * @property {number} hash - The hash used as the vector key * @property {number} index - The index of the message in the chat + * @property {boolean} [summaryFailed] - Whether summarization failed for this message (used internally to skip messages that fail summarization) */ const MODULE_NAME = 'vectors'; @@ -77,10 +78,13 @@ const settings = { summarize_sent: false, summary_source: 'main', summary_prompt: 'Ignore previous instructions. Summarize the most important parts of the message. Limit yourself to 250 words or less. Your response should include nothing but the summary.', + summary_retries: 2, + summary_threshold: 200, force_chunk_delimiter: '', // For chats enabled_chats: false, + keep_hidden: false, template: 'Past events:\n{{text}}', depth: 2, position: extension_prompt_types.IN_PROMPT, @@ -117,9 +121,76 @@ const settings = { const moduleWorker = new ModuleWorkerWrapper(synchronizeChat); const webllmProvider = new WebLlmVectorProvider(); +/** + * Cache for storing summaries of messages by their hash. + * @type {Map} + */ const cachedSummaries = new Map(); +/** + * Hashes skipped this Vectorize All session (summary or embed failure). Cleared on next Vectorize All click. + * @type {Set} + */ +const skippedHashes = new Set(); +/** + * Error causes treated as fatal — abort Vectorize All rather than skip. + * @type {Set} + */ +const FATAL_CAUSES = new Set(['account_id_missing', 'api_key_missing', 'api_url_missing', 'api_model_missing', 'extras_module_missing', 'webllm_not_supported', 'summary_endpoint_invalid']); const vectorApiRequiresUrl = ['llamacpp', 'vllm', 'ollama', 'koboldcpp']; +/** + * @typedef {object} RemoteEmbeddingEndpointConfig + * @property {string} url - The API endpoint URL + * @property {string} settingsKey - The key in settings for the selected model + * @property {string} selectId - The ID of the select element (without #) + * @property {string} [valueProperty='id'] - Property name for the option value + * @property {string} [textProperty] - Property name for the option text. Falls back to valueProperty + * @property {() => object} [getBody] - Function returning the request body + * @property {(models: any[]) => any[]} [filter] - Optional post-fetch filter for models + */ + +/** @type {Record} */ +const remoteEmbeddingEndpoints = { + chutes: { + url: '/api/openai/chutes/models/embedding', + settingsKey: 'chutes_model', + selectId: 'vectors_chutes_model', + valueProperty: 'slug', + textProperty: 'name', + }, + nanogpt: { + url: '/api/openai/nanogpt/models/embedding', + settingsKey: 'nanogpt_model', + selectId: 'vectors_nanogpt_model', + textProperty: 'name', + }, + electronhub: { + url: '/api/openai/electronhub/models', + settingsKey: 'electronhub_model', + selectId: 'vectors_electronhub_model', + textProperty: 'name', + filter: models => models.filter(m => Array.isArray(m?.endpoints) && m.endpoints.includes('/v1/embeddings')), + }, + openrouter: { + url: '/api/openrouter/models/embedding', + settingsKey: 'openrouter_model', + selectId: 'vectors_openrouter_model', + textProperty: 'name', + }, + siliconflow: { + url: '/api/openai/siliconflow/models/embedding', + settingsKey: 'siliconflow_model', + selectId: 'vectors_siliconflow_model', + getBody: () => ({ siliconflow_endpoint: oai_settings.siliconflow_endpoint }), + }, + workers_ai: { + url: '/api/openai/workers-ai/models/embedding', + settingsKey: 'workers_ai_model', + selectId: 'vectors_workers_ai_model', + getBody: () => ({ workers_ai_account_id: oai_settings.workers_ai_account_id }), + }, +}; + /** * Gets the Collection ID for a file embedded in the chat. * @param {string} fileUrl URL of the file @@ -145,10 +216,12 @@ async function onVectorizeAllClick() { // Clear all cached summaries to ensure that new ones are created // upon request of a full vectorise cachedSummaries.clear(); + skippedHashes.clear(); const batchSize = getBatchSize(); const elapsedLog = []; let finished = false; + let initialPending = null; // total items pending at the start of this run — set on first sync return $('#vectorize_progress').show(); $('#vectorize_progress_percent').text('0'); $('#vectorize_progress_eta').text('...'); @@ -162,16 +235,27 @@ async function onVectorizeAllClick() { const startTime = Date.now(); const remaining = await synchronizeChat(batchSize); const elapsed = Date.now() - startTime; + + if (remaining === null) { + // synchronizeChat already surfaced a toast; bail out of the loop. + throw new Error('Vectorization aborted'); + } + elapsedLog.push(elapsed); finished = remaining <= 0; - const total = getContext().chat.length; - const processed = total - remaining; - const processedPercent = Math.round((processed / total) * 100); // percentage of the work done + if (initialPending === null) { + initialPending = Math.max(0, remaining + batchSize); + } + const pending = Math.max(0, remaining); + const processed = Math.max(0, initialPending - pending); + const processedPercent = initialPending > 0 + ? Math.min(100, Math.round((processed / initialPending) * 100)) + : 100; const lastElapsed = elapsedLog.slice(-5); // last 5 elapsed times const averageElapsed = lastElapsed.reduce((a, b) => a + b, 0) / lastElapsed.length; // average time needed to process one item const pace = averageElapsed / batchSize; // time needed to process one item - const remainingTime = Math.round(pace * remaining / 1000); + const remainingTime = Math.round(pace * pending / 1000); $('#vectorize_progress_percent').text(processedPercent); $('#vectorize_progress_eta').text(remainingTime); @@ -180,6 +264,9 @@ async function onVectorizeAllClick() { throw new Error('Chat changed'); } } + if (skippedHashes.size > 0) { + toastr.warning(`${skippedHashes.size} message(s) skipped due to errors. Click Vectorize All again to retry.`, 'Vectorization partial'); + } } catch (error) { console.error('Vectors: Failed to vectorize all', error); } finally { @@ -250,7 +337,7 @@ async function summarizeExtra(element) { if (apiResult.ok) { const data = await apiResult.json(); - element.text = data.summary; + element.text = removeReasoningFromString(data.summary); } } catch (error) { console.log(error); @@ -282,45 +369,70 @@ async function summarizeWebLLM(element) { } const messages = [{ role: 'system', content: settings.summary_prompt }, { role: 'user', content: element.text }]; - element.text = await generateWebLlmChatPrompt(messages); + element.text = removeReasoningFromString(await generateWebLlmChatPrompt(messages)); return true; } /** - * Summarizes messages using the chosen method. - * @param {HashedMessage[]} hashedMessages Array of hashed messages + * Runs one summarization attempt for a single element via the chosen endpoint. + * @param {HashedMessage} element + * @param {string} endpoint + * @returns {Promise} Whether the attempt succeeded. + */ +async function summarizeOne(element, endpoint) { + switch (endpoint) { + case 'main': + return await summarizeMain(element); + case 'extras': + return await summarizeExtra(element); + case 'webllm': + return await summarizeWebLLM(element); + default: + throw new Error(`Unsupported summary endpoint: ${endpoint}`, { cause: 'summary_endpoint_invalid' }); + } +} + +/** + * Summarizes messages using the chosen method. Every returned element has been + * summarized (via live call or cache). Throws if any element fails after + * `settings.summary_retries` attempts. + * @param {HashedMessage[]} hashedMessages Array of hashed messages (mutated in place) * @param {string} endpoint Type of endpoint to use + * @param {Object} [options] Options for summarization behavior + * @param {boolean} [options.skipOnFailure=false] If true, tags failed elements with `summaryFailed = true` instead of throwing * @returns {Promise} Summarized messages */ -async function summarize(hashedMessages, endpoint = 'main') { +async function summarize(hashedMessages, endpoint = 'main', { skipOnFailure = false } = {}) { + const maxAttempts = Math.max(1, Number(settings.summary_retries) || 1); for (const element of hashedMessages) { const cachedSummary = cachedSummaries.get(element.hash); - if (!cachedSummary) { - let success = true; - switch (endpoint) { - case 'main': - success = await summarizeMain(element); - break; - case 'extras': - success = await summarizeExtra(element); - break; - case 'webllm': - success = await summarizeWebLLM(element); - break; - default: - console.error('Unsupported endpoint', endpoint); - success = false; - break; - } - if (success) { - cachedSummaries.set(element.hash, element.text); - } else { - break; - } - } else { + if (cachedSummary) { element.text = cachedSummary; + continue; } + + let success = false; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + success = await summarizeOne(element, endpoint); + if (success) break; + } catch (error) { + if (FATAL_CAUSES.has(error?.cause)) throw error; + console.warn(`Vectors: summary attempt ${attempt}/${maxAttempts} threw for hash ${element.hash}`, error); + } + console.warn(`Vectors: summary attempt ${attempt}/${maxAttempts} failed for hash ${element.hash}`); + } + if (!success) { + if (skipOnFailure) { + console.warn(`Vectors: summarization exhausted ${maxAttempts} attempt(s) for hash ${element.hash} — marking for skip`); + element.summaryFailed = true; + continue; + } + + throw new Error(`Summarization failed after ${maxAttempts} attempt(s)`, { cause: 'summary_failed' }); + } + cachedSummaries.set(element.hash, element.text); } return hashedMessages; } @@ -347,21 +459,43 @@ async function synchronizeChat(batchSize = 5) { return -1; } - const hashedMessages = context.chat.filter(x => !x.is_system).map(x => ({ text: String(substituteParams(x.mes)), hash: getStringHash(substituteParams(x.mes)), index: context.chat.indexOf(x) })); + /** @type {HashedMessage[]} */ + const hashedMessages = context.chat.filter(x => settings.keep_hidden || !x.is_system).map(x => ({ text: String(substituteParams(x.mes)), hash: getStringHash(substituteParams(x.mes)), index: context.chat.indexOf(x) })); const hashesInCollection = await getSavedHashes(chatId); - let newVectorItems = hashedMessages.filter(x => !hashesInCollection.includes(x.hash)); + const newVectorItems = hashedMessages + .filter(x => !hashesInCollection.includes(x.hash)) + .filter(x => !skippedHashes.has(x.hash)); const deletedHashes = hashesInCollection.filter(x => !hashedMessages.some(y => y.hash === x)); + let batch = newVectorItems.slice(0, batchSize); + if (settings.summarize) { - newVectorItems = await summarize(newVectorItems, settings.summary_source); + const minLength = Math.max(0, Number(settings.summary_threshold) || 0); + const toSummarize = minLength > 0 ? batch.filter(x => x.text.length >= minLength) : batch; + if (toSummarize.length > 0) { + await summarize(toSummarize, settings.summary_source, { skipOnFailure: true }); + const failed = toSummarize.filter(x => x.summaryFailed); + if (failed.length > 0) { + for (const item of failed) skippedHashes.add(item.hash); + batch = batch.filter(x => !x.summaryFailed); + } + } } - if (newVectorItems.length > 0) { - const chunkedBatch = splitByChunks(newVectorItems.slice(0, batchSize)); + if (batch.length > 0) { + const chunkedBatch = splitByChunks(batch); - console.log(`Vectors: Found ${newVectorItems.length} new items. Processing ${batchSize}...`); - await insertVectorItems(chatId, chunkedBatch); + console.log(`Vectors: Found ${newVectorItems.length} new items. Processing ${batch.length}...`); + try { + await insertVectorItems(chatId, chunkedBatch); + } catch (insertError) { + if (FATAL_CAUSES.has(insertError?.cause)) { + throw insertError; + } + console.warn('Vectors: insert failed for batch — marking for skip', insertError); + for (const item of batch) skippedHashes.add(item.hash); + } } if (deletedHashes.length > 0) { @@ -388,6 +522,12 @@ async function synchronizeChat(batchSize = 5) { return 'Extras API must provide an "embeddings" module.'; case 'webllm_not_supported': return 'WebLLM extension is not installed or the model is not set.'; + case 'account_id_missing': + return 'Workers AI account ID is required. Save it in the "API Connections" panel.'; + case 'summary_endpoint_invalid': + return 'Summarization endpoint is not supported.'; + case 'summary_failed': + return 'Summarization failed after the configured number of retries.'; default: return 'Check server console for more details'; } @@ -397,7 +537,7 @@ async function synchronizeChat(batchSize = 5) { const message = getErrorMessage(error.cause); toastr.error(message, 'Vectorization failed', { preventDuplicates: true }); - return -1; + return null; } finally { syncBlocked = false; } @@ -771,7 +911,11 @@ async function getQueryText(chat, initiator) { .slice(0, settings.query); if (initiator === 'chat' && settings.enabled_chats && settings.summarize && settings.summarize_sent) { - hashedMessages = await summarize(hashedMessages, settings.summary_source); + const minLength = Math.max(0, Number(settings.summary_threshold) || 0); + const toSummarize = minLength > 0 ? hashedMessages.filter(x => x.text.length >= minLength) : hashedMessages; + if (toSummarize.length > 0) { + await summarize(toSummarize, settings.summary_source, { skipOnFailure: true }); + } } const queryText = hashedMessages.map(x => x.text).join('\n'); @@ -842,6 +986,10 @@ function getVectorsRequestBody(args = {}) { body.model = extension_settings.vectors.siliconflow_model; body.siliconflow_endpoint = oai_settings.siliconflow_endpoint; break; + case 'workers_ai': + body.model = extension_settings.vectors.workers_ai_model || '@cf/baai/bge-m3'; + body.workers_ai_account_id = oai_settings.workers_ai_account_id; + break; default: break; } @@ -935,6 +1083,7 @@ function throwIfSourceInvalid() { settings.source === 'togetherai' && !secret_state[SECRET_KEYS.TOGETHERAI] || settings.source === 'nomicai' && !secret_state[SECRET_KEYS.NOMICAI] || settings.source === 'cohere' && !secret_state[SECRET_KEYS.COHERE] || + settings.source === 'workers_ai' && !secret_state[SECRET_KEYS.WORKERS_AI] || settings.source === 'siliconflow' && !secret_state[SECRET_KEYS.SILICONFLOW]) { throw new Error('Vectors: API key missing', { cause: 'api_key_missing' }); } @@ -963,6 +1112,10 @@ function throwIfSourceInvalid() { if (settings.source === 'webllm' && (!isWebLlmSupported() || !settings.webllm_model)) { throw new Error('Vectors: WebLLM is not supported', { cause: 'webllm_not_supported' }); } + + if (settings.source === 'workers_ai' && !oai_settings.workers_ai_account_id) { + throw new Error('Vectors: Workers AI account ID missing', { cause: 'account_id_missing' }); + } } /** @@ -972,11 +1125,12 @@ function throwIfSourceInvalid() { * @returns {Promise} */ async function deleteVectorItems(collectionId, hashes) { + const args = await getAdditionalArgs([]); const response = await fetch('/api/vector/delete', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ - ...getVectorsRequestBody(), + ...getVectorsRequestBody(args), collectionId: collectionId, hashes: hashes, source: settings.source, @@ -1154,211 +1308,77 @@ function toggleSettings() { $('#koboldcpp_vectorsModel').toggle(settings.source === 'koboldcpp'); $('#google_vectorsModel').toggle(settings.source === 'palm' || settings.source === 'vertexai'); $('#siliconflow_vectorsModel').toggle(settings.source === 'siliconflow'); + $('#workers_ai_vectorsModel').toggle(settings.source === 'workers_ai'); $('#vector_altEndpointUrl').toggle(vectorApiRequiresUrl.includes(settings.source)); - switch (settings.source) { - case 'webllm': - loadWebLlmModels(); - break; - case 'electronhub': - loadElectronHubModels(); - break; - case 'openrouter': - loadOpenRouterModels(); - break; - case 'chutes': - loadChutesModels(); - break; - case 'nanogpt': - loadNanoGPTModels(); - break; - case 'siliconflow': - loadSiliconFlowModels(); - break; - } -} - -async function loadChutesModels() { - try { - const response = await fetch('/api/openai/chutes/models/embedding', { - method: 'POST', - headers: getRequestHeaders({ omitContentType: true }), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - /** @type {Array} */ - const data = await response.json(); - const models = Array.isArray(data) ? data : []; - populateChutesModelSelect(models); - } catch (err) { - console.warn('Chutes models fetch failed', err); - populateChutesModelSelect([]); - } -} - -function populateChutesModelSelect(models) { - const select = $('#vectors_chutes_model'); - select.empty(); - for (const m of models) { - const option = document.createElement('option'); - option.value = m.slug; - option.text = m.name; - select.append(option); - } - if (!settings.chutes_model && models.length) { - settings.chutes_model = models[0].slug; - } - $('#vectors_chutes_model').val(settings.chutes_model); -} - -async function loadNanoGPTModels() { - try { - const response = await fetch('/api/openai/nanogpt/models/embedding', { - method: 'POST', - headers: getRequestHeaders({ omitContentType: true }), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - /** @type {Array} */ - const data = await response.json(); - const models = Array.isArray(data) ? data : []; - populateNanoGPTModelSelect(models); - } catch (err) { - console.warn('NanoGPT models fetch failed', err); - populateNanoGPTModelSelect([]); - } -} - -function populateNanoGPTModelSelect(models) { - const select = $('#vectors_nanogpt_model'); - select.empty(); - for (const m of models) { - const option = document.createElement('option'); - option.value = m.id; - option.text = m.name || m.id; - select.append(option); - } - if (!settings.nanogpt_model && models.length) { - settings.nanogpt_model = models[0].id; - } - $('#vectors_nanogpt_model').val(settings.nanogpt_model); -} - -async function loadElectronHubModels() { - try { - const response = await fetch('/api/openai/electronhub/models', { - method: 'POST', - headers: getRequestHeaders({ omitContentType: true }), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - /** @type {Array} */ - const data = await response.json(); - // filter by embeddings endpoint - const models = Array.isArray(data) ? data.filter(m => Array.isArray(m?.endpoints) && m.endpoints.includes('/v1/embeddings')) : []; - populateElectronHubModelSelect(models); - } catch (err) { - console.warn('Electron Hub models fetch failed', err); - populateElectronHubModelSelect([]); + if (settings.source === 'webllm') { + loadWebLlmModels(); + } else if (settings.source in remoteEmbeddingEndpoints) { + loadRemoteEmbeddingModels(settings.source); } } /** - * Populates the Electron Hub model select element. - * @param {{ id: string, name: string }[]} models Electron Hub models + * Loads models from a remote embedding endpoint and populates the corresponding select element. + * @param {string} source - The source key matching a remoteEmbeddingEndpoints entry */ -function populateElectronHubModelSelect(models) { - const select = $('#vectors_electronhub_model'); - select.empty(); - for (const m of models) { - const option = document.createElement('option'); - option.value = m.id; - option.text = m.name || m.id; - select.append(option); +async function loadRemoteEmbeddingModels(source) { + const config = remoteEmbeddingEndpoints[source]; + if (!config) { + return; } - if (!settings.electronhub_model && models.length) { - settings.electronhub_model = models[0].id; - } - $('#vectors_electronhub_model').val(settings.electronhub_model); -} -async function loadOpenRouterModels() { - try { - const response = await fetch('/api/openrouter/models/embedding', { - method: 'POST', - headers: getRequestHeaders({ omitContentType: true }), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + const { url, settingsKey, selectId, getBody, filter } = config; + const valueProperty = config.valueProperty || 'id'; + const textProperty = config.textProperty; + + /** + * Populates the select element with the given models. + * @param {any[]} models - Array of model objects + */ + function populateSelect(models) { + const select = $(`#${selectId}`); + select.empty(); + for (const m of models) { + const option = document.createElement('option'); + option.value = m[valueProperty]; + option.text = textProperty ? (m[textProperty] || m[valueProperty]) : m[valueProperty]; + select.append(option); } - /** @type {Array} */ - const data = await response.json(); - const models = Array.isArray(data) ? data : []; - populateOpenRouterModelSelect(models); - } catch (err) { - console.warn('OpenRouter models fetch failed', err); - populateOpenRouterModelSelect([]); + if (!settings[settingsKey] && models.length) { + settings[settingsKey] = models[0][valueProperty]; + Object.assign(extension_settings.vectors, settings); + saveSettingsDebounced(); + } + select.val(settings[settingsKey]); } -} -/** - * Populates the OpenRouter model select element. - * @param {{ id: string, name: string }[]} models OpenRouter models - */ -function populateOpenRouterModelSelect(models) { - const select = $('#vectors_openrouter_model'); - select.empty(); - for (const m of models) { - const option = document.createElement('option'); - option.value = m.id; - option.text = m.name || m.id; - select.append(option); - } - if (!settings.openrouter_model && models.length) { - settings.openrouter_model = models[0].id; - } - $('#vectors_openrouter_model').val(settings.openrouter_model); -} - -async function loadSiliconFlowModels() { try { - const response = await fetch('/api/openai/siliconflow/models/embedding', { + const body = typeof getBody === 'function' ? getBody() : {}; + + /** @type {RequestInit} */ + const fetchOptions = { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ - siliconflow_endpoint: oai_settings.siliconflow_endpoint, - }), - }); + body: JSON.stringify(body || {}), + }; + const response = await fetch(url, fetchOptions); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } /** @type {Array} */ const data = await response.json(); - const models = Array.isArray(data) ? data : []; - populateSiliconFlowModelSelect(models); - } catch (err) { - console.warn('SiliconFlow models fetch failed', err); - populateSiliconFlowModelSelect([]); - } -} + let models = Array.isArray(data) ? data : []; + if (filter) { + models = filter(models); + } -function populateSiliconFlowModelSelect(models) { - const select = $('#vectors_siliconflow_model'); - select.empty(); - for (const m of models) { - const option = document.createElement('option'); - option.value = m.id; - option.text = m.id; - select.append(option); + populateSelect(models); + } catch (err) { + console.warn(`${source} models fetch failed`, err); + populateSelect([]); } - if (!settings.siliconflow_model && models.length) { - settings.siliconflow_model = models[0].id; - } - $('#vectors_siliconflow_model').val(settings.siliconflow_model); } /** @@ -1496,10 +1516,11 @@ async function onViewStatsClick() { { timeOut: 10000, escapeHtml: false }, ); + $('#chat .mes.vectorized').removeClass('vectorized'); const chat = getContext().chat; for (const message of chat) { if (hashesInCollection.includes(getStringHash(substituteParams(message.mes)))) { - const messageElement = $(`.mes[mesid="${chat.indexOf(message)}"]`); + const messageElement = $(`#chat .mes[mesid="${chat.indexOf(message)}"]`); messageElement.addClass('vectorized'); } } @@ -1704,7 +1725,7 @@ async function activateWorldInfo(chat) { await eventSource.emit(event_types.WORLDINFO_FORCE_ACTIVATE, activatedEntries); } -jQuery(async () => { +export async function init() { if (!extension_settings.vectors) { extension_settings.vectors = settings; } @@ -1726,6 +1747,11 @@ jQuery(async () => { saveSettingsDebounced(); toggleSettings(); }); + $('#vectors_keep_hidden').prop('checked', settings.keep_hidden).on('input', () => { + settings.keep_hidden = !!$('#vectors_keep_hidden').prop('checked'); + Object.assign(extension_settings.vectors, settings); + saveSettingsDebounced(); + }); $('#vectors_enabled_files').prop('checked', settings.enabled_files).on('input', () => { settings.enabled_files = $('#vectors_enabled_files').prop('checked'); Object.assign(extension_settings.vectors, settings); @@ -1778,6 +1804,11 @@ jQuery(async () => { Object.assign(extension_settings.vectors, settings); saveSettingsDebounced(); }); + $('#vectors_workers_ai_model').val(settings.workers_ai_model).on('change', () => { + settings.workers_ai_model = String($('#vectors_workers_ai_model').val()); + Object.assign(extension_settings.vectors, settings); + saveSettingsDebounced(); + }); $('#vectors_openrouter_model').val(settings.openrouter_model).on('change', () => { settings.openrouter_model = String($('#vectors_openrouter_model').val()); Object.assign(extension_settings.vectors, settings); @@ -1888,6 +1919,20 @@ jQuery(async () => { saveSettingsDebounced(); }); + $('#vectors_summary_retries').val(settings.summary_retries).on('input', () => { + const parsed = Number($('#vectors_summary_retries').val()); + settings.summary_retries = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : 1; + Object.assign(extension_settings.vectors, settings); + saveSettingsDebounced(); + }); + + $('#vectors_summary_threshold').val(settings.summary_threshold).on('input', () => { + const parsed = Number($('#vectors_summary_threshold').val()); + settings.summary_threshold = Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0; + Object.assign(extension_settings.vectors, settings); + saveSettingsDebounced(); + }); + $('#vectors_message_chunk_size').val(settings.message_chunk_size).on('input', () => { settings.message_chunk_size = Number($('#vectors_message_chunk_size').val()); Object.assign(extension_settings.vectors, settings); @@ -2310,4 +2355,4 @@ jQuery(async () => { } await purgeAllVectorIndexes(); }); -}); +} diff --git a/public/scripts/extensions/vectors/manifest.json b/public/scripts/extensions/vectors/manifest.json index 5f2462832..1849d9c88 100644 --- a/public/scripts/extensions/vectors/manifest.json +++ b/public/scripts/extensions/vectors/manifest.json @@ -10,5 +10,8 @@ "css": "style.css", "author": "Cohee#1207", "version": "1.0.0", - "homePage": "https://github.com/SillyTavern/SillyTavern" + "homePage": "https://github.com/SillyTavern/SillyTavern", + "hooks": { + "activate": "init" + } } diff --git a/public/scripts/extensions/vectors/settings.html b/public/scripts/extensions/vectors/settings.html index 1d86a3922..50847efd5 100644 --- a/public/scripts/extensions/vectors/settings.html +++ b/public/scripts/extensions/vectors/settings.html @@ -11,6 +11,7 @@ + + Hint: Set your Workers AI API key and Account ID in API Connections. + +
+
- ${t`This slash command works for most of the Text Completion sources, KoboldAI Classic, and also Custom OpenAI compatible, Z.AI, SiliconFlow, and Google Vertex AI for the Chat Completion sources. If unsure which APIs are supported, check the auto-completion of the optional api argument of this command.`} + ${t`This slash command works for most of the Text Completion sources, KoboldAI Classic, and also Custom OpenAI compatible, Z.AI, SiliconFlow, MiniMax, and Google Vertex AI for the Chat Completion sources. If unsure which APIs are supported, check the auto-completion of the optional api argument of this command.`}
`, })); @@ -5016,23 +5018,6 @@ async function triggerGenerationCallback(args, value) { return ''; } -/** - * Find persona by name. - * @param {string} name Name to search for - * @returns {string} Persona name - */ -function findPersonaByName(name) { - if (!name) { - return null; - } - - for (const persona of Object.entries(power_user.personas)) { - if (equalsIgnoreCaseAndAccents(persona[1], name)) { - return persona[0]; - } - } - return null; -} async function sendUserMessageCallback(args, text) { text = String(text ?? '').trim(); @@ -5050,7 +5035,7 @@ async function sendUserMessageCallback(args, text) { let message; if ('name' in args) { const name = args.name || ''; - const avatar = findPersonaByName(name) || user_avatar; + const avatar = findPersona({ name })?.avatar || user_avatar; message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar); } else { message = await sendMessageAsUser(text, bias, insertAt, compact); @@ -5128,105 +5113,6 @@ async function openChat(chid) { await reloadCurrentChat(); } -/** - * Opens a file picker dialog for selecting an image. - * @returns {Promise} Base64 data URL of selected image, or null if cancelled - */ -async function promptForAvatarFile() { - return new Promise(resolve => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = supportedImageMimeTypes.join(','); - input.onchange = async (e) => { - if (!(e.target instanceof HTMLInputElement)) { - return ''; - } - const file = e.target?.files?.[0]; - if (!file) { - resolve(null); - return; - } - try { - const converted = await ensureImageFormatSupported(file); - const base64 = await getBase64Async(converted); - resolve(base64); - } catch (error) { - console.error('Error processing selected image:', error); - toastr.error(t`Failed to process selected image: ${error.message}`); - resolve(null); - } - }; - input.oncancel = () => resolve(null); - input.click(); - }); -} - -/** - * Resolves avatar data from various input formats (base64, local path, or prompt). - * @param {string} input - "prompt" to open file picker, base64 data URL, or local file path - * @returns {Promise} Base64 data URL or null if invalid/cancelled - */ -async function resolveAvatarData(input) { - if (!input || typeof input !== 'string') { - return null; - } - - const trimmed = input.trim(); - - // Special value "prompt" opens file picker - if (trimmed.toLowerCase() === 'prompt') { - return await promptForAvatarFile(); - } - - // Already a base64 data URL - if (trimmed.startsWith('data:image/')) { - return trimmed; - } - - // External URLs are not supported - if (isExternalUrl(trimmed)) { - toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`); - return null; - } - // Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin - // Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/* - // Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png) - if (trimmed.includes('/') || trimmed.endsWith('.png')) { - try { - // Construct the URL to fetch the local file - let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - // Handle same-origin URLs - if (trimmed.startsWith(window.location.origin)) { - url = new URL(trimmed).pathname; - } - // If there is no subfolder, we guess this should be a character image - if (!url.includes('/', 1)) { - url = '/characters/' + trimmed; - } - - const response = await fetch(url); - if (!response.ok) { - throw new Error(`File not found or inaccessible: ${response.status}`); - } - const blob = await response.blob(); - if (!blob.type.startsWith('image/')) { - throw new Error('File is not an image'); - } - const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type })); - return await getBase64Async(converted); - } catch (error) { - console.error('Error fetching local avatar:', error); - toastr.warning(t`Failed to load avatar from path: ${error.message}`); - return null; - } - } - - // Unknown format - console.warn('Unknown avatar format:', trimmed.substring(0, 50)); - toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`); - return null; -} - /** * Uploads an avatar image to a character. * @param {string} avatarKey - The character's avatar filename (e.g., "name.png") @@ -6376,6 +6262,7 @@ function getModelOptions(quiet) { { id: 'model_groq_select', api: 'openai', type: chat_completion_sources.GROQ }, { id: 'model_chutes_select', api: 'openai', type: chat_completion_sources.CHUTES }, { id: 'model_siliconflow_select', api: 'openai', type: chat_completion_sources.SILICONFLOW }, + { id: 'model_minimax_select', api: 'openai', type: chat_completion_sources.MINIMAX }, { id: 'model_electronhub_select', api: 'openai', type: chat_completion_sources.ELECTRONHUB }, { id: 'model_nanogpt_select', api: 'openai', type: chat_completion_sources.NANOGPT }, { id: 'model_deepseek_select', api: 'openai', type: chat_completion_sources.DEEPSEEK }, @@ -6386,6 +6273,7 @@ function getModelOptions(quiet) { { id: 'model_fireworks_select', api: 'openai', type: chat_completion_sources.FIREWORKS }, { id: 'model_cometapi_select', api: 'openai', type: chat_completion_sources.COMETAPI }, { id: 'model_zai_select', api: 'openai', type: chat_completion_sources.ZAI }, + { id: 'model_workers_ai_select', api: 'openai', type: chat_completion_sources.WORKERS_AI }, { id: 'model_novel_select', api: 'novel', type: null }, { id: 'horde_model', api: 'koboldhorde', type: null }, ]; @@ -6742,6 +6630,32 @@ async function setApiUrlCallback({ api = null, connect = 'true', quiet = 'false' return oai_settings.siliconflow_endpoint || SILICONFLOW_ENDPOINT.GLOBAL; } + const isCurrentlyMinimax = main_api === 'openai' && oai_settings.chat_completion_source === chat_completion_sources.MINIMAX; + if (api === chat_completion_sources.MINIMAX || (!api && isCurrentlyMinimax)) { + if (!url) { + return oai_settings.minimax_endpoint || MINIMAX_ENDPOINT.GLOBAL; + } + + const permittedValues = Object.values(MINIMAX_ENDPOINT); + if (!permittedValues.includes(url)) { + !isQuiet && toastr.warning(t`Valid options are: ${permittedValues.join(', ')}`, t`MiniMax endpoint '${url}' is not a valid option.`); + return ''; + } + + if (!isCurrentlyMinimax && autoConnect) { + toastr.warning(t`MiniMax is not the currently selected API, so we cannot do an auto-connect. Consider switching to it via /api beforehand.`); + return ''; + } + + $('#minimax_endpoint').val(url).trigger('input'); + + if (autoConnect) { + $('#api_button_openai').trigger('click'); + } + + return oai_settings.minimax_endpoint || MINIMAX_ENDPOINT.GLOBAL; + } + const isCurrentlyVertexAI = main_api === 'openai' && oai_settings.chat_completion_source === chat_completion_sources.VERTEXAI; if (api === chat_completion_sources.VERTEXAI || (!api && isCurrentlyVertexAI)) { const defaultRegion = 'us-central1'; diff --git a/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js b/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js index ff1c05853..430bfee27 100644 --- a/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js +++ b/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js @@ -214,9 +214,13 @@ export const commonEnumProviders = { /** * All possible personas * - * @returns {SlashCommandEnumValue[]} + * @returns {() => SlashCommandEnumValue[]} */ - personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)), + personas: ({ allowPersonaKey = false } = {}) => () => Object.entries(power_user.personas).map(([personaKey, personaName]) => { + const existsMultiple = Object.values(power_user.personas).filter(p => p === personaName).length > 1; + const returnValue = allowPersonaKey && existsMultiple ? personaKey : personaName; + return new SlashCommandEnumValue(returnValue, allowPersonaKey && existsMultiple ? personaName : null, enumTypes.name, enumIcons.persona); + }), /** * All possible tags, or only those that have been assigned @@ -234,9 +238,9 @@ export const commonEnumProviders = { * All possible tags for a given char/group entity * * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show - * @returns {() => SlashCommandEnumValue[]} + * @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]} */ - tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor) => { + tagsForChar: (mode = 'all') => (executor, _scope) => { // Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags. const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value; if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures'); @@ -341,4 +345,34 @@ export const commonEnumProviders = { backgrounds: () => Array.from(document.querySelectorAll('.bg_example')) .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile'))) .filter(it => it.value?.length), + + connectionProfiles: ({ includeNone = false } = {}) => () => [ + ...includeNone ? [new SlashCommandEnumValue('')] : [], + ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)), + ], +}; + + +/** + * A collection of common enum match providers + * + * Can be used on `SlashCommandEnumValue` and their `matchProvider` property. + */ +export const commonEnumMatchProviders = { + /** + * Provides autocomplete matching for folder-like enum values. + * Matches if the input starts with the check or vice versa (case-insensitive). + * @param {string} input - The input string to match against + * @param {string} check - The check string to match with + * @param {object} [options={}] - Options + * @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty + * @returns {boolean} - True if the strings match according to the folder matching rules + */ + folderEnum: (input, check, { trueOnEmpty = true } = {}) => { + if (!check) return false; + if (!input) return trueOnEmpty; + const inputLower = input.toLowerCase(); + const checkLower = check.toLowerCase(); + return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower); + }, }; diff --git a/public/scripts/st-context.js b/public/scripts/st-context.js index 21eba82ca..01701de7b 100644 --- a/public/scripts/st-context.js +++ b/public/scripts/st-context.js @@ -71,12 +71,15 @@ import { } from '../script.js'; import { extension_settings, + getExtensionManifest, ModuleWorkerWrapper, openThirdPartyExtensionMenu, renderExtensionTemplate, renderExtensionTemplateAsync, saveMetadataDebounced, + UNSET_VALUE, writeExtensionField, + writeExtensionFieldBulk, } from './extensions.js'; import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js'; import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js'; @@ -101,7 +104,7 @@ import { ToolManager } from './tool-calling.js'; import { accountStorage } from './util/AccountStorage.js'; import { timestampToMoment, uuidv4, importFromExternalUrl } from './utils.js'; import { addGlobalVariable, addLocalVariable, decrementGlobalVariable, decrementLocalVariable, deleteGlobalVariable, deleteLocalVariable, existsGlobalVariable, existsLocalVariable, getGlobalVariable, getLocalVariable, incrementGlobalVariable, incrementLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js'; -import { convertCharacterBook, getWorldInfoPrompt, loadWorldInfo, reloadEditor, saveWorldInfo, updateWorldInfoList } from './world-info.js'; +import { convertCharacterBook, getWorldInfoPrompt, loadWorldInfo, reloadEditor, saveWorldInfo, updateWorldInfoList, world_names } from './world-info.js'; import { ChatCompletionService, TextCompletionService } from './custom-request.js'; import { ConnectionManagerRequestService } from './extensions/shared.js'; import { updateReasoningUI, parseReasoningFromString, getReasoningTemplateByName } from './reasoning.js'; @@ -201,6 +204,7 @@ export function getContext() { generateRaw, generateRawData, writeExtensionField, + writeExtensionFieldBulk, getThumbnailUrl, selectCharacterById, messageFormatting, @@ -275,6 +279,7 @@ export function getContext() { updateWorldInfoList, convertCharacterBook, getWorldInfoPrompt, + getWorldInfoNames: () => Array.isArray(world_names) ? [...world_names] : [], CONNECT_API_MAP, getTextGenServer, extractMessageFromData, @@ -290,10 +295,14 @@ export function getContext() { getReasoningTemplateByName, unshallowCharacter, unshallowGroupMembers, + getExtensionManifest, openThirdPartyExtensionMenu, symbols: { ignore: IGNORE_SYMBOL, }, + constants: { + unset: UNSET_VALUE, + }, }; } diff --git a/public/scripts/streaming-display.js b/public/scripts/streaming-display.js new file mode 100644 index 000000000..8a378e016 --- /dev/null +++ b/public/scripts/streaming-display.js @@ -0,0 +1,430 @@ +/** + * A floating toast-like display panel for showing streaming LLM generation progress. + * Shows reasoning (thinking) and content as they stream in. + * Designed to work with ConnectionManagerRequestService streaming responses. + * + * Appends itself inside the topmost open `` element (same approach as + * fixToastrForDialogs in popup.js) so it renders above modal overlays. + * + * @example + * const display = new StreamingDisplay(); + * display.show({ label: 'Generating...' }); + * + * for await (const chunk of streamGenerator) { + * display.updateReasoning(chunk.state?.reasoning) + * .updateContent(chunk.text); + * } + * + * display.complete('Generated Something'); // Mark as done (green LED, auto-hide if configured) + */ + +import { SVGInject } from '../lib.js'; +import { t } from './i18n.js'; +import { animation_duration, messageFormatting } from '/script.js'; + +/** CSS class prefix */ +const CSS_PREFIX = 'streaming-display'; + +/** + * @typedef {Object} StreamingDisplayOptions + * @property {string} [label] - Header label (e.g. "Generating greeting...") + * @property {HTMLImageElement} [icon] - Optional API/model icon image (e.g. from createModelIcon). Will be SVG-injected when loaded. + * @property {(() => (void | Promise)) | null} [onStop] - Optional stop handler. When provided, a stop button is shown. Clicking it invokes this handler only — the display is not automatically hidden or completed. + */ + +export class StreamingDisplay { + /** @type {HTMLElement | null} */ + #element = null; + /** @type {HTMLElement | null} */ + #labelElement = null; + /** @type {HTMLElement | null} */ + #labelText = null; + /** @type {HTMLElement | null} */ + #reasoningSection = null; + /** @type {HTMLElement | null} */ + #reasoningContent = null; + /** @type {HTMLElement | null} */ + #textSection = null; + /** @type {HTMLElement | null} */ + #textContent = null; + /** @type {HTMLButtonElement | null} */ + #stopButton = null; + /** @type {HTMLButtonElement | null} */ + #minimizeButton = null; + /** @type {HTMLButtonElement | null} */ + #closeButton = null; + /** @type {(() => (void | Promise)) | null} */ + #onStop = null; + /** @type {HTMLElement | null} */ + #ledIndicator = null; + /** @type {boolean} */ + #hasContent = false; + /** @type {boolean} */ + #isMinimized = false; + /** @type {boolean} */ + #isComplete = false; + /** @type {boolean} */ + #isStopped = false; + /** @type {ReturnType | null} */ + #hideTimeoutId = null; + + /** + * Shows the streaming display panel. + * @param {StreamingDisplayOptions} [options] + * @returns {StreamingDisplay} this instance for chaining + */ + show({ label = '', icon = null, onStop = null } = {}) { + if (this.#element) this.hide({ instant: true }); + + this.#isMinimized = false; + this.#isComplete = false; + this.#onStop = onStop; + this.#clearHideTimeout(); + + this.#element = document.createElement('div'); + this.#element.classList.add(CSS_PREFIX); + + // Header label with LED indicator + this.#labelElement = document.createElement('div'); + this.#labelElement.classList.add(`${CSS_PREFIX}-label`); + + // LED status indicator (pulsing while streaming, green when complete) + this.#ledIndicator = document.createElement('span'); + this.#ledIndicator.classList.add(`${CSS_PREFIX}-led`); + this.#labelElement.appendChild(this.#ledIndicator); + + // Insert model icon into the label (after the LED) + if (icon instanceof HTMLImageElement) { + icon.classList.add(`${CSS_PREFIX}-icon`); + this.#labelElement.appendChild(icon); + icon.onload = async function () { + await SVGInject(icon); + }; + } + + this.#labelText = document.createElement('span'); + this.#labelText.classList.add(`${CSS_PREFIX}-label-text`); + this.#labelText.textContent = label; + this.#labelElement.appendChild(this.#labelText); + + // Window control buttons container + const controls = document.createElement('div'); + controls.classList.add(`${CSS_PREFIX}-controls`); + + // Stop button (only shown when an onStop handler is provided) + if (onStop) { + this.#stopButton = document.createElement('button'); + this.#stopButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-stop`); + this.#stopButton.setAttribute('aria-label', t`Stop`); + this.#stopButton.setAttribute('title', t`Stop generation`); + this.#stopButton.innerHTML = '■'; // Black square ■ + this.#stopButton.addEventListener('click', async () => { + // Disable immediately to prevent double-clicks and give instant feedback + if (this.#stopButton) { + this.#stopButton.disabled = true; + } + try { + await this.#onStop?.(); + } catch (e) { + console.error('[StreamingDisplay] Error executing stop handler', e); + } + }); + controls.appendChild(this.#stopButton); + } + + // Minimize button + this.#minimizeButton = document.createElement('button'); + this.#minimizeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-minimize`); + this.#minimizeButton.setAttribute('aria-label', t`Minimize`); + this.#minimizeButton.setAttribute('title', t`Minimize`); + this.#minimizeButton.innerHTML = '–'; // En dash + this.#minimizeButton.addEventListener('click', () => this.toggleMinimize()); + controls.appendChild(this.#minimizeButton); + + // Close button + this.#closeButton = document.createElement('button'); + this.#closeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-close`); + this.#closeButton.setAttribute('aria-label', t`Close`); + this.#closeButton.setAttribute('title', t`Close (generation continues in background)`); + this.#closeButton.innerHTML = '×'; // Multiplication sign (×) + this.#closeButton.addEventListener('click', () => this.hide()); + controls.appendChild(this.#closeButton); + + this.#labelElement.appendChild(controls); + this.#element.appendChild(this.#labelElement); + + // Content container (for minimize functionality) + const contentContainer = document.createElement('div'); + contentContainer.classList.add(`${CSS_PREFIX}-content`); + + // Reasoning section (hidden until content arrives) + this.#reasoningSection = document.createElement('div'); + this.#reasoningSection.classList.add(`${CSS_PREFIX}-reasoning`); + this.#reasoningSection.style.display = 'none'; + + const reasoningLabel = document.createElement('div'); + reasoningLabel.classList.add(`${CSS_PREFIX}-reasoning-label`); + reasoningLabel.textContent = t`Thinking...`; + this.#reasoningSection.appendChild(reasoningLabel); + + this.#reasoningContent = document.createElement('div'); + this.#reasoningContent.classList.add(`${CSS_PREFIX}-reasoning-content`); + this.#reasoningSection.appendChild(this.#reasoningContent); + + contentContainer.appendChild(this.#reasoningSection); + + // Content section (hidden until content arrives) + this.#textSection = document.createElement('div'); + this.#textSection.classList.add(`${CSS_PREFIX}-text`); + this.#textSection.style.display = 'none'; + + this.#textContent = document.createElement('div'); + this.#textContent.classList.add(`${CSS_PREFIX}-text-content`, 'mes_text'); // Allow formatting based on how chat messages are formatted too + this.#textSection.appendChild(this.#textContent); + + contentContainer.appendChild(this.#textSection); + this.#element.appendChild(contentContainer); + + // Append inside the topmost open dialog (same pattern as fixToastrForDialogs in popup.js). + // Modal elements live in the browser's top layer, so z-index alone won't work. + const target = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop() ?? document.body; + target.appendChild(this.#element); + + // Trigger entrance animation on next frame + requestAnimationFrame(() => { + this.#element?.classList.add(`${CSS_PREFIX}-visible`); + }); + + return this; + } + + /** + * Toggles the minimized state of the display. + * When minimized, only the header with label and buttons is shown. + * @returns {StreamingDisplay} this instance for chaining + */ + toggleMinimize() { + if (!this.#element) return this; + + this.#isMinimized = !this.#isMinimized; + this.#element.classList.toggle(`${CSS_PREFIX}-minimized`, this.#isMinimized); + + // Update minimize button icon/appearance + if (this.#minimizeButton) { + this.#minimizeButton.innerHTML = this.#isMinimized ? '□' : '–'; // Square when minimized, dash when not + this.#minimizeButton.setAttribute('title', this.#isMinimized ? t`Restore` : t`Minimize`); + this.#minimizeButton.setAttribute('aria-label', this.#isMinimized ? t`Restore` : t`Minimize`); + } + + return this; + } + + /** + * @returns {boolean} Whether the display is currently minimized + */ + get isMinimized() { + return this.#isMinimized; + } + + /** + * @returns {boolean} Whether the display is marked as complete (generation finished) + */ + get isComplete() { + return this.#isComplete; + } + + /** + * @returns {boolean} Whether the display was stopped by the user + */ + get isStopped() { + return this.#isStopped; + } + + /** + * Updates the header label text. + * @param {string} label + * @returns {StreamingDisplay} this instance for chaining + */ + setLabel(label) { + if (this.#labelText) { + this.#labelText.textContent = label; + } + return this; + } + + /** + * Updates the reasoning (thinking) section with new text. + * Automatically shows the reasoning section when text is provided. + * @param {string} text - Accumulated reasoning text + * @returns {StreamingDisplay} this instance for chaining + */ + updateReasoning(text) { + if (!this.#reasoningContent || !this.#reasoningSection || !text) return this; + + this.#reasoningSection.style.display = ''; + this.#reasoningContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, true); + this.#reasoningContent.scrollTop = this.#reasoningContent.scrollHeight; + return this; + } + + /** + * Updates the main content section with new text. + * Automatically shows the content section when text is provided (including empty string). + * @param {string|null|undefined} text - Accumulated content text + * @returns {StreamingDisplay} this instance for chaining + */ + updateContent(text) { + if (!this.#textContent || !this.#textSection || !text) return this; + + this.#hasContent = true; + this.#textSection.style.display = ''; + this.#textContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, false); + this.#textContent.scrollTop = this.#textContent.scrollHeight; + return this; + } + + /** @returns {boolean} Whether any content text has been displayed via streaming */ + get hasContent() { + return this.#hasContent; + } + + /** + * Marks the generation as stopped by the user. + * + * Changes the LED indicator to solid red, removes the stop button, and keeps the display + * visible until the user manually closes it with the close button (no auto-hide). + * + * @param {Object} [options={}] + * @param {string|null} [options.label=null] - Optional label override (e.g. `'Generating... [Stopped]'`). + * @returns {StreamingDisplay} this instance for chaining + */ + markStopped({ label = null } = {}) { + if (!this.#element || this.#isStopped || this.#isComplete) return this; + + this.#isStopped = true; + this.#clearHideTimeout(); + this.#element.classList.add(`${CSS_PREFIX}-stopped`); + + // Remove the stop button — nothing left to stop + if (this.#stopButton) { + this.#stopButton.remove(); + this.#stopButton = null; + } + + if (label !== null) { + this.setLabel(label); + } + + return this; + } + + /** + * Marks the generation as complete and initiates cleanup. Optionally set a new label. + * + * This is the **preferred method** to call after streaming ends. It: + * - Changes the LED indicator from pulsing orange to solid green + * - Waits for the specified delay to let the user see the final result + * - Then hides the display with a fade-out animation + * + * @param {Object} [options={}] + * @param {string|null} [options.label=null] - Set the label automatically to a new one to display the completed state. + * @param {number|null} [options.delay=3000] - Delay in ms before hiding. Use `null` or negative value to keep displayed until user manually closes it. + * @returns {StreamingDisplay} this instance for chaining + */ + complete({ label = null, delay = 3000 } = {}) { + if (!this.#element || this.#isComplete) return this; + + this.#isComplete = true; + this.#element.classList.add(`${CSS_PREFIX}-complete`); + + // Clear any existing hide timeout + this.#clearHideTimeout(); + + if (this.#stopButton) { + this.#stopButton.remove(); + this.#stopButton = null; + } + if (label !== null) { + this.setLabel(label); + } + + // Auto-hide after delay if specified (positive number) + if (typeof delay === 'number' && delay >= 0) { + this.#hideTimeoutId = setTimeout(() => { + this.#performHide(); + }, delay); + } + + return this; + } + + /** + * Immediately hides and removes the streaming display. + * + * **Note:** This is for immediate cleanup (e.g., when canceling generation + * or closing the app). Prefer `complete()` when generation finishes normally, + * as it shows the green LED and gives the user time to see the final result. + * + * @param {Object} [options={}] + * @param {boolean} [options.instant=false] - Skip the fade-out animation + * @returns {StreamingDisplay} this instance for chaining + */ + hide({ instant = false } = {}) { + this.#clearHideTimeout(); + this.#performHide({ instant }); + return this; + } + + /** + * Clears any pending auto-hide timeout. + */ + #clearHideTimeout() { + if (this.#hideTimeoutId !== null) { + clearTimeout(this.#hideTimeoutId); + this.#hideTimeoutId = null; + } + } + + /** + * Internal method to actually remove the DOM element. + * @param {Object} [options={}] + * @param {boolean} [options.instant=false] + */ + #performHide({ instant = false } = {}) { + if (!this.#element) return; + + const el = this.#element; + + // Clear all private fields + this.#element = null; + this.#labelElement = null; + this.#labelText = null; + this.#reasoningSection = null; + this.#reasoningContent = null; + this.#textSection = null; + this.#textContent = null; + this.#stopButton = null; + this.#minimizeButton = null; + this.#closeButton = null; + this.#ledIndicator = null; + this.#onStop = null; + this.#hasContent = false; + this.#isMinimized = false; + this.#isComplete = false; + this.#isStopped = false; + this.#hideTimeoutId = null; + + if (instant) { + el.remove(); + return; + } + + el.classList.remove(`${CSS_PREFIX}-visible`); + const duration = animation_duration; + if (duration > 0) { + setTimeout(() => el.remove(), duration); + } else { + el.remove(); + } + } +} diff --git a/public/scripts/swipe-picker.js b/public/scripts/swipe-picker.js index 2116973ee..843cc45d8 100644 --- a/public/scripts/swipe-picker.js +++ b/public/scripts/swipe-picker.js @@ -3,8 +3,9 @@ import { SWIPE_DIRECTION, SWIPE_SOURCE } from './constants.js'; import { t } from './i18n.js'; import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; import { power_user } from './power-user.js'; +import { isMobile } from './RossAscends-mods.js'; import { getTokenCountAsync } from './tokenizers.js'; -import { clamp, timestampToMoment } from './utils.js'; +import { addLongPressEvent, clamp, copyText, timestampToMoment } from './utils.js'; import { chat, deleteSwipe, ensureSwipes, isMessageSwipeable, isSwipingAllowed, swipe, syncMesToSwipe } from '/script.js'; /** @@ -132,6 +133,7 @@ async function openSwipePicker(messageId) { const template = $('#past_chat_template .select_chat_block_wrapper').clone(); const block = template.find('.select_chat_block'); block.removeClass('select_chat_block').addClass('swipe_picker_block'); + block.find('.select_chat_actions').removeClass('gap10px'); const branchButton = template.find('.exportRawChatButton'); const deleteButton = template.find('.PastChat_cross'); const swipeInfo = Array.isArray(message.swipe_info) ? message.swipe_info[index] : null; @@ -162,7 +164,7 @@ async function openSwipePicker(messageId) { 'data-i18n': '[title]Create Branch', }) .removeClass('exportRawChatButton fa-solid fa-file-export') - .addClass('swipe_picker_branch mes_button fa-regular fa-code-branch') + .addClass('swipe_picker_branch mes_button fa-fw fa-regular fa-code-branch') .on('click', async (event) => { event.preventDefault(); event.stopPropagation(); @@ -174,7 +176,7 @@ async function openSwipePicker(messageId) { .removeAttr('file_name') .attr('aria-disabled', String(!canDeleteSwipe)) .removeClass('fa-skull') - .addClass('swipe_picker_delete fa-trash-can') + .addClass('swipe_picker_delete fa-fw fa-trash-can') .toggleClass('hoverglow', canDeleteSwipe) .toggleClass('disabled', !canDeleteSwipe) .each(function () { @@ -229,11 +231,42 @@ async function openSwipePicker(messageId) { await renderSwipeList(); }); + + // Add expand/collapse toggle + const expandCheckboxId = `swipe_picker_expand_${messageId}_${index}`; + const expandCheckbox = document.createElement('input'); + expandCheckbox.type = 'checkbox'; + expandCheckbox.id = expandCheckboxId; + expandCheckbox.classList.add('swipe_picker_expand_toggle'); + block[0].prepend(expandCheckbox); + + const expandLabel = document.createElement('label'); + expandLabel.htmlFor = expandCheckboxId; + expandLabel.classList.add('swipe_picker_expand_label', 'fa-solid', 'fa-fw', 'fa-chevron-down'); + expandLabel.title = t`Expand/Collapse`; + expandLabel.setAttribute('data-i18n', '[title]Expand/Collapse'); + expandLabel.addEventListener('click', (event) => event.stopPropagation()); + + // Add copy button + const copyButton = document.createElement('div'); + copyButton.classList.add('swipe_picker_copy', 'fa-solid', 'fa-fw', 'fa-copy'); + copyButton.title = t`Copy`; + copyButton.setAttribute('data-i18n', '[title]Copy'); + copyButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await copyText(swipeText); + toastr.info(t`Copied!`, '', { timeOut: 2000 }); + }); + + // Insert new buttons before the branch button + branchButton.before(expandLabel, copyButton); + template.find('.select_chat_block_filename').text(`#${index + 1}${index === Number(message.swipe_id ?? 0) ? ` ${t`[Current]`}` : ''}`); template.find('.chat_messages_date').text(sendDate); template.find('.chat_file_size').text(swipeDetails.length ? `(${swipeDetails[0]}${swipeDetails.length > 1 ? ',' : ')'}` : ''); template.find('.chat_messages_num').text(swipeDetails.length > 1 ? `${swipeDetails.slice(1).join(', ')})` : ''); - template.find('.select_chat_block_mes').text(previewText || t`(empty swipe)`); + template.find('.select_chat_block_mes').text(previewText ? swipeText : t`(empty swipe)`); block.on('click', () => setSelectedSwipe(index)); block.on('dblclick', async () => { @@ -269,6 +302,7 @@ async function openSwipePicker(messageId) { defaultState: String(selectedSwipeId + 1), tooltip: `1-${message.swipes.length}`, }], + large: true, wider: true, allowVerticalScrolling: true, onOpen: function () { @@ -376,22 +410,29 @@ async function openSwipePicker(messageId) { } export function initSwipePicker() { - $(document).on('click', '.swipes-counter.swipe-picker-enabled', async function (e) { + /** + * Click handler for opening the swipe picker when clicking on the swipe counter. + * @param {JQuery.Event | Event} e Event object + */ + async function onSwipeCounterClick(e) { e.preventDefault(); e.stopPropagation(); const mesId = Number($(this).closest('.mes').attr('mesid')); await openSwipePicker(mesId); - }); + } + + if (isMobile()) { + addLongPressEvent('.swipes-counter.swipe-picker-enabled', onSwipeCounterClick); + } else { + $(document).on('click', '.swipes-counter.swipe-picker-enabled', onSwipeCounterClick); + } $(document).on('keydown', '.swipes-counter.swipe-picker-enabled', async function (e) { if (e.key !== ' ') { return; } - e.preventDefault(); - e.stopPropagation(); - const mesId = Number($(this).closest('.mes').attr('mesid')); - await openSwipePicker(mesId); + onSwipeCounterClick.call(this, e); }); $(document).on('click', '.mes_swipe_picker', async function (e) { e.preventDefault(); diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 1a49b871b..e2b144f00 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -16,7 +16,7 @@ import { import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groupMembersFilter, groups, selected_group } from './group-chats.js'; -import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js'; +import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar, escapeHtml } from './utils.js'; import { power_user } from './power-user.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; @@ -977,11 +977,12 @@ async function importTags(character, { importSetting = null } = {}) { const tagsToImport = tagNamesToImport.map(tag => getTag(tag, { createNew: true })); const added = addTagsToEntity(tagsToImport, character.avatar); + const tagNames = tagsToImport.map(x => escapeHtml(x.name)).join(', '); if (added) { - toastr.success(t`Imported tags:` + `
${tagsToImport.map(x => x.name).join(', ')}`, t`Importing Tags`, { escapeHtml: false }); + toastr.success(t`Imported tags:` + `
${tagNames}`, t`Importing Tags`, { escapeHtml: false }); } else { - toastr.error(t`Couldn't import tags:` + `
${tagsToImport.map(x => x.name).join(', ')}`, t`Importing Tags`, { escapeHtml: false }); + toastr.error(t`Couldn't import tags:` + `
${tagNames}`, t`Importing Tags`, { escapeHtml: false }); } return added; @@ -1124,7 +1125,7 @@ function getTag(tagName, { createNew = false } = {}) { function createNewTag(tagName) { const existing = getTag(tagName); if (existing) { - toastr.warning(`Cannot create new tag. A tag with the name already exists:
${existing.name}`, 'Creating Tag', { escapeHtml: false }); + toastr.warning(`Cannot create new tag. A tag with the name already exists:
${escapeHtml(existing.name)}`, 'Creating Tag', { escapeHtml: false }); return existing; } @@ -2762,37 +2763,10 @@ function extractCharacterAvatar(avatarSrc) { function restoreSavedTagFilters() { try { - const validStates = new Set(Object.keys(FILTER_STATES)); - const readState = (/** @type {string} */ storageKey) => { - const v = accountStorage.getItem(storageKey); - return v && validStates.has(v) ? v : null; - }; - - const favState = readState(ACTIONABLE_FILTER_STORAGE_KEYS.FAV); - const groupState = readState(ACTIONABLE_FILTER_STORAGE_KEYS.GROUP); - const folderState = readState(ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER); - - if (favState) { - ACTIONABLE_TAGS.FAV.filter_state = favState; - entitiesFilter.setFilterData(FILTER_TYPES.FAV, favState, true); - } - // Load persisted filter states for all contexts (including character list) loadFilterStatesForContext(entitiesFilter, 'CharacterList'); loadFilterStatesForContext(groupCandidatesFilter, 'GroupCandidates'); loadFilterStatesForContext(groupMembersFilter, 'GroupMembers'); - if (groupState) { - ACTIONABLE_TAGS.GROUP.filter_state = groupState; - entitiesFilter.setFilterData(FILTER_TYPES.GROUP, groupState, true); - } - if (folderState) { - ACTIONABLE_TAGS.FOLDER.filter_state = folderState; - entitiesFilter.setFilterData(FILTER_TYPES.FOLDER, folderState, true); - } - - // Note: Regular tag filter states are now loaded from storage via loadFilterStatesForContext() - // The old tag.filter_state property is only maintained for backward compatibility with - // the main character list's actionable tags (Favorites, Groups, Folders) } catch (e) { console.warn('Failed to restore actionable filter states from account storage', e); } diff --git a/public/scripts/templates/thirdPartyExtensionWarning.html b/public/scripts/templates/thirdPartyExtensionWarning.html new file mode 100644 index 000000000..0b5979aa3 --- /dev/null +++ b/public/scripts/templates/thirdPartyExtensionWarning.html @@ -0,0 +1,18 @@ +

+ + The URL you provided does not seem to be an official SillyTavern extension repository. + +

+

+ + Using third-party extensions can have unintended side effects and may pose security risks. + + + Always make sure you trust the source before importing an extension. We are not responsible for any damage caused by third-party extensions. + +

+

+ + Are you sure you want to proceed? + +

diff --git a/public/scripts/templates/welcomePanel.html b/public/scripts/templates/welcomePanel.html index 809eeefe8..b29a4fe06 100644 --- a/public/scripts/templates/welcomePanel.html +++ b/public/scripts/templates/welcomePanel.html @@ -5,6 +5,9 @@
+
+ +
diff --git a/public/scripts/textgen-models.js b/public/scripts/textgen-models.js index a3373618a..1af689e10 100644 --- a/public/scripts/textgen-models.js +++ b/public/scripts/textgen-models.js @@ -31,6 +31,7 @@ const OPENROUTER_PROVIDERS = [ 'AI21', 'AionLabs', 'Alibaba', + 'AkashML', 'Amazon Bedrock', 'Amazon Nova', 'Ambient', @@ -39,6 +40,7 @@ const OPENROUTER_PROVIDERS = [ 'AtlasCloud', 'Avian', 'Azure', + 'Baidu', 'BaseTen', 'Black Forest Labs', 'Cerebras', @@ -50,6 +52,7 @@ const OPENROUTER_PROVIDERS = [ 'Crusoe', 'DeepInfra', 'DeepSeek', + 'DekaLLM', 'FakeProvider', 'Featherless', 'Fireworks', @@ -64,6 +67,8 @@ const OPENROUTER_PROVIDERS = [ 'InferenceNet', 'Infermatic', 'Inflection', + 'Io Net', + 'Ionstream', 'Liquid', 'Mancer 2', 'Mara', @@ -83,6 +88,8 @@ const OPENROUTER_PROVIDERS = [ 'Parasail', 'Perplexity', 'Phala', + 'Recraft', + 'Reka', 'Relace', 'SambaNova', 'Seed', @@ -101,6 +108,214 @@ const OPENROUTER_PROVIDERS = [ 'Z.AI', ]; +/** + * List of NanoGPT providers. + * Providers endpoint: https://nano-gpt.com/api/models/providers + * @type {{id: string, label: string}[]} + */ +const NANOGPT_PROVIDERS = [ + { + 'id': 'akash', + 'label': 'Akash', + }, + { + 'id': 'alibaba', + 'label': 'Alibaba', + }, + { + 'id': 'ambient', + 'label': 'Ambient', + }, + { + 'id': 'arliai', + 'label': 'ArliAI', + }, + { + 'id': 'atlascloud', + 'label': 'AtlasCloud', + }, + { + 'id': 'azure', + 'label': 'Azure', + }, + { + 'id': 'awsbedrock', + 'label': 'Amazon Bedrock', + }, + { + 'id': 'baidu', + 'label': 'Baidu', + }, + { + 'id': 'baseten', + 'label': 'BaseTen', + }, + { + 'id': 'cerebras', + 'label': 'Cerebras', + }, + { + 'id': 'chutes', + 'label': 'Chutes', + }, + { + 'id': 'clarifai', + 'label': 'Clarifai', + }, + { + 'id': 'cloudflare', + 'label': 'Cloudflare', + }, + { + 'id': 'crusoe', + 'label': 'Crusoe', + }, + { + 'id': 'dekallm', + 'label': 'DekaLLM', + }, + { + 'id': 'deepinfra', + 'label': 'DeepInfra', + }, + { + 'id': 'deepseek', + 'label': 'DeepSeek', + }, + { + 'id': 'fireworks', + 'label': 'Fireworks', + }, + { + 'id': 'friendli', + 'label': 'Friendli', + }, + { + 'id': 'gmicloud', + 'label': 'GMICloud', + }, + { + 'id': 'lilac', + 'label': 'Lilac', + }, + { + 'id': 'google', + 'label': 'Google', + }, + { + 'id': 'groq', + 'label': 'Groq', + }, + { + 'id': 'hyperbolic', + 'label': 'Hyperbolic', + }, + { + 'id': 'ionet', + 'label': 'Io Net', + }, + { + 'id': 'inceptron', + 'label': 'Inceptron', + }, + { + 'id': 'mancer', + 'label': 'Mancer', + }, + { + 'id': 'mara', + 'label': 'Mara', + }, + { + 'id': 'meganova', + 'label': 'MegaNova', + }, + { + 'id': 'minimax', + 'label': 'MiniMax', + }, + { + 'id': 'modelrun', + 'label': 'ModelRun', + }, + { + 'id': 'moonshot', + 'label': 'Moonshot', + }, + { + 'id': 'morph', + 'label': 'Morph', + }, + { + 'id': 'ncompass', + 'label': 'NCompass', + }, + { + 'id': 'nebius', + 'label': 'Nebius', + }, + { + 'id': 'neuralwatt', + 'label': 'Neuralwatt', + }, + { + 'id': 'nextbit', + 'label': 'NextBit', + }, + { + 'id': 'novita', + 'label': 'Novita', + }, + { + 'id': 'parasail', + 'label': 'Parasail', + }, + { + 'id': 'phala', + 'label': 'Phala', + }, + { + 'id': 'redpill', + 'label': 'Redpill', + }, + { + 'id': 'sambanova', + 'label': 'SambaNova', + }, + { + 'id': 'sambanova-high-throughput', + 'label': 'SambaNova (High Throughput)', + }, + { + 'id': 'siliconflow', + 'label': 'SiliconFlow', + }, + { + 'id': 'streamlake', + 'label': 'StreamLake', + }, + { + 'id': 'tinfoil', + 'label': 'Tinfoil', + }, + { + 'id': 'together', + 'label': 'Together', + }, + { + 'id': 'venice', + 'label': 'Venice', + }, + { + 'id': 'wandb', + 'label': 'Weights & Biases', + }, + { + 'id': 'zai', + 'label': 'Z.AI', + }, +]; + const OPENROUTER_PROVIDER_WARNING_SELECTORS = { '#openrouter_providers_text': { fallbackSelector: '#openrouter_allow_fallbacks_textgenerationwebui', @@ -180,6 +395,72 @@ export async function syncOpenRouterProvidersForModel(modelId, providersSelector } } +export async function syncNanoGptProvidersForModel(modelId, providersSelector) { + const $providers = $(providersSelector); + + const refreshWarningState = () => { + updateNanoGptProvidersWarning(providersSelector); + }; + + if (!modelId) { + $providers.find('option').prop('disabled', false); + $providers.trigger('change.select2'); + refreshWarningState(); + return; + } + + try { + const response = await fetch('/api/nanogpt/models/providers', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ model: modelId }), + }); + + if (!response.ok) { + refreshWarningState(); + return; + } + + const data = await response.json(); + const providerIds = Array.isArray(data?.providers) ? data.providers : []; + + if (!data?.supportsProviderSelection || providerIds.length === 0) { + $providers.find('option').each(function () { + $(this).prop('disabled', Boolean($(this).val())); + }); + $providers.trigger('change').trigger('change.select2'); + refreshWarningState(); + return; + } + + $providers.find('option').each(function () { + const value = $(this).val(); + const isAvailable = !value || providerIds.includes(value); + $(this).prop('disabled', !isAvailable); + }); + + $providers.trigger('change.select2'); + refreshWarningState(); + } catch (error) { + console.error('Failed to fetch NanoGPT providers for model', error); + refreshWarningState(); + } +} + +export function updateNanoGptProvidersWarning(providersSelector) { + const $providers = $(providersSelector); + + if ($providers.length === 0) { + return; + } + + const selectedCount = $providers.find('option:selected').length; + const applicableSelectedCount = $providers.find('option:selected:not(:disabled)').length; + const showWarning = selectedCount > 0 && applicableSelectedCount === 0; + + $('#nanogpt_provider_warning').toggleClass('displayNone', !showWarning); +} + export async function loadOllamaModels(data) { if (!Array.isArray(data)) { console.error('Invalid Ollama models data', data); @@ -1077,6 +1358,14 @@ export function initTextGenModels() { })); } + const nanoGptProvidersSelect = $('#nanogpt_provider'); + for (const provider of NANOGPT_PROVIDERS) { + nanoGptProvidersSelect.append($('