Merge pull request #5154 from SillyTavern/staging Staging

e3b866b5d2bcc7fbaa889bb926fbb567cd1ed25b

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
141 files changed, +5660 -1189Ignore whitespace
.dockerignore+55 -15
@@ -1,21 +1,61 @@
1+# --- Git & CI ---
12.git
23.github
34.vscodegitignore
4-node_modules
5+
5-npm-debug.log
6+# --- Docker ---
6-readme*
7+/Dockerfile
7-Start.bat
8+/.dockerignore
8-/dist
9+/docker/docker-compose.yml
9-/backups
10-cloudflared.exe
11-access.log
12-/data
13-/cache
14-.DS_Store
15-/public/scripts/extensions/third-party
16-/colab
17-.gemini
1810/docker/config
1911/docker/extensions
2012/docker/data
2113/docker/plugins
14+/public/scripts/extensions/third-party
15+
16+# --- Plugins (keep only package files) ---
17+/plugins/*
18+!/plugins/package.json
19+!/plugins/package-lock.json
20+
21+# --- The Folders ---
22+/backups
23+/cache
24+/colab
25+/data
26+/dist
27+/node_modules
28+/tests
29+
30+# --- Sensitive Info ---
31+**/.env*
32+**/*.pem
33+**/certs
34+
35+# --- Documentation ---
36+readme*
37+*.md
38+Update-Instructions.txt
39+
40+# --- OS & System Junk ---
41+**/.DS_Store
42+*.bat
43+*.cmd
44+*.exe
45+start.sh
46+
47+# --- Dev Config ---
48+.editorconfig
49+.eslintrc.cjs
50+.eslintrc*
51+.vscode
52+**/jsconfig.json
53+.npmignore
54+.gemini
55+replit.nix
56+.replit
57+.nomedia
58+
59+# -- Logs & Temp ---
60+*.log
61+**/tmp
.eslintrc.cjs+1 -1
@@ -98,7 +98,7 @@ module.exports = {
9898 'no-cond-assign': 'error',
9999 'no-unneeded-ternary': 'error',
100100 'no-irregular-whitespace': ['error', { skipStrings: true, skipTemplates: true }],
101-
101+ 'dot-notation': ['error', { 'allowPattern': '[A-Z]\\w*$' }],
102102 // These rules should eventually be enabled.
103103 'no-async-promise-executor': 'off',
104104 'no-inner-declarations': 'off',
Dockerfile+15 -11
@@ -1,44 +1,48 @@
11FROM node:lts-alpine3.2223
22
33# Arguments
44ARG APP_HOME=/home/node/app
55
66# Install system dependencies
7-RUN apk add --no-cache gcompat tini git git-lfs
7+# "Don't rely on the base image for tools; if you call it, you install it." ;)
8+RUN apk add --no-cache gcompat tini git git-lfs su-exec shadow dos2unix
89
910# Create app directory and set ownership
1011WORKDIR ${APP_HOME}
12+RUN chown node:node ${APP_HOME}
1113
1214# Set NODE_ENV to production
1315ENV NODE_ENV=production
1416
1517# Bundle app source and set ownership
1618COPY --chown=node:node . ./
1719
1820RUN \
1921 echo "*** Install npm packages ***" && \
2022 npm ci --no-audit --no-fund --loglevel=error --no-progress --omit=dev && npm cache clean --force
2123
2224# Create config directory and link config.yaml. Added hardcoded dirs(constants.js?)
25+# that must be present for Non-Root Mode and volumeless docker runs.
2326RUN \
2427 rm -f "config.yaml" || true && \
25- ln -s "./config/config.yaml" "config.yaml" || true && \
28+ mkdir -p config data plugins public/scripts/extensions/third-party backups && \
26- mkdir "config" || true
29+ chown -R node:node config data plugins public/scripts/extensions/third-party backups && \
30+ ln -s "./config/config.yaml" "config.yaml"
2731
2832# Pre-compile public libraries
2933RUN \
3034 echo "*** Run Webpack ***" && \
3135 node "./docker/build-lib.js"
3236
3337# Set the entrypoint script and cleanup
3438RUN \
3539 echo "*** Cleanup ***" && \
3640 mv "./docker/docker-entrypoint.sh" "./" && \
37- rm -rf "./docker" && \
3841 echo "*** Make docker-entrypoint.sh executable ***" && \
3942 chmod +x "./docker-entrypoint.sh" && \
4043 echo "*** Convert line endings to Unix format ***" && \
4144 dos2unix "./docker-entrypoint.sh" && \
45+ rm -rf "./docker"
4246
4347# Fix extension repos permissions
4448RUN git config --global --add safe.directory "*"
default/config.yaml+25 -2
@@ -38,6 +38,9 @@ browserLaunch:
3838 avoidLocalhost: false
3939# Server port
4040port: 8000
41+# Interval in seconds to write a heartbeat file. Set to 0 to disable.
42+# This is used primarily for Docker healthchecks.
43+heartbeatInterval: 0
4144# -- SSL options --
4245ssl:
4346 # Enable SSL/TLS encryption
@@ -68,6 +71,25 @@ basicAuthUser:
6871 password: "password"
6972# Enables CORS proxy middleware
7073enableCorsProxy: false
74+# CORS settings (applied to all routes)
75+cors:
76+ # Enable or disable CORS middleware
77+ enabled: true
78+ # Allowed origins. Use "null" to match the default browser file origin.
79+ # You can set "*" to allow any origin, or a list of allowed origins.
80+ origin:
81+ - "null"
82+ # Allowed methods
83+ methods:
84+ - "OPTIONS"
85+ # Allowed request headers (optional)
86+ allowedHeaders: []
87+ # Exposed response headers (optional)
88+ exposedHeaders: []
89+ # Allow credentials (cookies, authorization headers)
90+ credentials: false
91+ # Preflight cache max age in seconds (optional)
92+ maxAge: null
7193# -- REQUEST PROXY CONFIGURATION --
7294requestProxy:
7395 # If a proxy is enabled, all outgoing HTTP/HTTPS requests will be routed through it.
@@ -200,7 +222,6 @@ whitelistImportDomains:
200222 - cdn.discordapp.com
201223 - files.catbox.moe
202224 - raw.githubusercontent.com
203- - char-archive.evulid.cc
204225# API request overrides (for KoboldAI and Text Completion APIs)
205226## Note: host includes the port number if it's not the default (80 or 443)
206227## Format is an array of objects:
@@ -265,7 +286,7 @@ ollama:
265286# -- ANTHROPIC CLAUDE API CONFIGURATION --
266287claude:
267288 # Enables caching of the system prompt (if supported).
268289 # https://docsplatform.anthropicclaude.com/en/docs/en/build-with-claude/prompt-caching
269290 # -- IMPORTANT! --
270291 # Use only when the prompt before the chat history is static and doesn't change between requests
271292 # (e.g {{random}} macro or lorebooks not as in-chat injections).
@@ -287,6 +308,8 @@ claude:
287308gemini:
288309 # API endpoint version ("v1beta" or "v1alpha")
289310 apiVersion: 'v1beta'
311+ # Adds thought signatures to requests (if available). Only for Gemini 3 and above.
312+ thoughtSignatures: true
290313 # Enables caching of the system prompt (if supported). Only for OpenRouter.
291314 # -- IMPORTANT! --
292315 # Use only when the prompt before the chat history is static and doesn't change between requests
docker/docker-compose.yml+7 -0
@@ -7,6 +7,7 @@ services:
77 environment:
88 - NODE_ENV=production
99 - FORCE_COLOR=1
10+ - SILLYTAVERN_HEARTBEATINTERVAL=30
1011 ports:
1112 - "8000:8000"
1213 volumes:
@@ -14,4 +15,10 @@ services:
1415 - "./data:/home/node/app/data"
1516 - "./plugins:/home/node/app/plugins"
1617 - "./extensions:/home/node/app/public/scripts/extensions/third-party"
18+ healthcheck:
19+ test: ["CMD", "node", "src/healthcheck.js"]
20+ interval: 30s
21+ timeout: 10s
22+ start_period: 20s
23+ retries: 3
1724 restart: unless-stopped
docker/docker-entrypoint.sh+95 -8
@@ -1,12 +1,99 @@
11#!/bin/sh
22
3-if [ ! -e "config/config.yaml" ]; then
3+# Function to handle startup logic (Config check + Postinstall + Start)
4- echo "Resource not found, copying from defaults: config.yaml"
4+start_sillytavern() {
5- cp -r "default/config.yaml" "config/config.yaml"
5+ local PREFIX="$1"
6-fi
6+ shift # Remove the first argument (PREFIX) so $@ contains the rest
7+
8+ # Config Check
9+ if [ ! -e "config/config.yaml" ]; then
10+ echo "Resource not found, copying from defaults: config.yaml"
11+ $PREFIX cp "default/config.yaml" "config/config.yaml"
12+ fi
13+
14+ # Execute postinstall to auto-populate config.yaml with missing values
15+ $PREFIX npm run postinstall
16+
17+ # Start the server
18+ exec $PREFIX node server.js --listen "$@"
19+}
20+
21+# Dirs that MUST be present at this point (e.g for volumeless docker runs).
22+# Please update list, if in the future a related perm issue appear.
23+CORE_DIRS="config data plugins public/scripts/extensions/third-party backups"
24+
25+# Mounted Volumes (External)
26+# Parse mounts, handling files vs directories
27+RAW_MOUNTS=$(awk -v app_path="/home/node/app" '$2 ~ "^" app_path {print $2}' /proc/mounts)
28+MOUNTED_DIRS=""
29+
30+for mount in $RAW_MOUNTS; do
31+ if [ -f "$mount" ]; then
32+ # If it is a mounted file (e.g. cert.pem), we want to check its PARENT directory
33+ # so that the app can write adjacent files (e.g. key.pem).
34+ PARENT_DIR=$(dirname "$mount")
35+
36+ # Performance Safety: If the file is in the root of the app,
37+ # we do NOT add the parent (App Root), or we will recursively scan the whole app.
38+ [ "$PARENT_DIR" != "/home/node/app" ] && MOUNTED_DIRS="$MOUNTED_DIRS $PARENT_DIR" || MOUNTED_DIRS="$MOUNTED_DIRS $mount"
39+ else
40+ # It is a directory, add it directly
41+ MOUNTED_DIRS="$MOUNTED_DIRS $mount"
42+ fi
43+done
44+
45+# Combine dirs for checks
46+CHECK_DIRS=$(echo "$CORE_DIRS $MOUNTED_DIRS" | tr ' ' '\n' | sort -u)
747
8-# Execute postinstall to auto-populate config.yaml with missing values
48+# Ensure the needed directories exist
9-npm run postinstall
49+for dir in $CHECK_DIRS; do
50+ if [ ! -e "$dir" ]; then
51+ echo "Creating missing directory: $dir"
52+ mkdir -p "$dir" 2>/dev/null || echo "Warning: Could not create $dir" >&2
53+ fi
54+done
55+
56+# Mode Selection
57+if [ "$(id -u)" = "0" ]; then
58+ # Check if PUID/PGID variables are provided
59+ if [ -n "$PUID" ] && [ -n "$PGID" ]; then
60+ echo "Mode: PUID/PGID (UID:$PUID GID:$PGID)"
61+
62+ # Update the internal 'node' user to match requested IDs
63+ groupmod -o -g "$PGID" node
64+ usermod -o -u "$PUID" -g "$PGID" node
65+
66+ for dir in $CHECK_DIRS; do
67+ if [ -d "$dir" ]; then
68+ # Runs chown only if there is an mismatch
69+ DIR_UID=$(stat -c '%u' "$dir")
70+ DIR_GID=$(stat -c '%g' "$dir")
71+
72+ if [ "$DIR_UID" != "$PUID" ] || [ "$DIR_GID" != "$PGID" ]; then
73+ echo "(Detected mismatch) Adjusting permissions for: $dir."
74+ chown -R node:node "$dir" || echo "Warning: Failed to update permissions for '$dir'." >&2
75+ fi
76+ fi
77+ done
78+
79+ # Fix config file specifically
80+ chown node:node "config/config.yaml" 2>/dev/null
81+
82+ # Set execution prefix to run as 'node' user
83+ EXEC_PREFIX="su-exec node:node"
84+ else
85+ # Default: Run as Root (original behavior)
86+ echo "Mode: Default (Root)"
87+ EXEC_PREFIX=""
88+ fi
89+
90+else
91+ # Non-Root Mode (Docker CLI --user flag)
92+ echo "Mode: Strict Non-Root (UID: $(id -u))"
93+ # We CANNOT auto-fix permissions in this mode because we lack privileges.
94+ # Relying solely on the user configuring their host permissions correctly.
95+ EXEC_PREFIX=""
96+fi
1097
1198# StartCalling function with the serverdetermined prefix
12-exec node server.js --listen "$@"
99+start_sillytavern "$EXEC_PREFIX" "$@"
package-lock.json+233 -164
@@ -1,12 +1,12 @@
11{
22 "name": "sillytavern",
33 "version": "1.1516.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "sillytavern",
99 "version": "1.1516.0",
1010 "hasInstallScript": true,
1111 "license": "AGPL-3.0",
1212 "dependencies": {
@@ -45,7 +45,7 @@
4545 "bowser": "^2.12.1",
4646 "bytes": "^3.1.2",
4747 "chalk": "^5.6.0",
4848 "chevrotain": "^11.01.31",
4949 "command-exists": "^1.2.9",
5050 "compression": "^1.8.1",
5151 "cookie-parser": "^1.4.6",
@@ -67,6 +67,7 @@
6767 "host-validation-middleware": "^0.1.1",
6868 "html-entities": "^2.6.0",
6969 "iconv-lite": "^0.6.3",
70+ "image-size": "^2.0.2",
7071 "ip-matching": "^2.1.2",
7172 "ip-regex": "^5.0.0",
7273 "ipaddr.js": "^2.2.0",
@@ -174,42 +175,42 @@
174175 "license": "Apache-2.0"
175176 },
176177 "node_modules/@chevrotain/cst-dts-gen": {
177178 "version": "11.1.01",
178179 "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.01.tgz",
179180 "integrity": "sha512-SafRHyv6/G9XD23V4StfHMeQNnXbFmj8CsYUBmf+L895f542qQqiRGalrfJl/hKm0RFDhxAfHzV6e58NA8j5ninntT5yqMzBW8QEbYxLkNUwevD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==",
180181 "license": "Apache-2.0",
181182 "dependencies": {
182183 "@chevrotain/gast": "11.1.01",
183184 "@chevrotain/types": "11.1.01",
184185 "lodash-es": "4.17.2123"
185186 }
186187 },
187188 "node_modules/@chevrotain/gast": {
188189 "version": "11.1.01",
189190 "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.01.tgz",
190191 "integrity": "sha512-0fyRYDFneUhbyV6k22R6bBY02Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+FasLqcxXYVt8z51IWTZ10l2Z2Lc0hiPTgm8MNRbYZnDbNv78b9zY5DoIJKjQdfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==",
191192 "license": "Apache-2.0",
192193 "dependencies": {
193194 "@chevrotain/types": "11.1.01",
194195 "lodash-es": "4.17.2123"
195196 }
196197 },
197198 "node_modules/@chevrotain/regexp-to-ast": {
198199 "version": "11.1.01",
199200 "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.01.tgz",
200201 "integrity": "sha512-3rW046uSp36liIAc/5G6A6h3gGbDN1eONpmJQpybIbctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+G2kSz0BNRc9ziT4DYrCUUbgNLd6bNVROqN9r7ZaajYg82C2uylg/TEwFRgwLmbhlln4qkmDyteg==",
201202 "license": "Apache-2.0"
202203 },
203204 "node_modules/@chevrotain/types": {
204205 "version": "11.1.01",
205206 "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.01.tgz",
206207 "integrity": "sha512-GXni/dwJAkClMfwCtrbGU19RXQ9O76hFxq3sgy/zufXNj3ov6J/8FOWIXxJLhnKx7gzSweATmRccjlpmr5W2nAwb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==",
207208 "license": "Apache-2.0"
208209 },
209210 "node_modules/@chevrotain/utils": {
210211 "version": "11.1.01",
211212 "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.01.tgz",
212213 "integrity": "sha512-DrS2yldzFnjmBV0O/kDngcFxWuqg2FdmUpaD6KyTmgIIE6lR53dq80R71eTYMzYXYSFPrbg/ZzZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+o6LpUrXsLJk192kXuaeIPic4WVgFE1TVQ==",
213214 "license": "Apache-2.0"
214215 },
215216 "node_modules/@es-joy/jsdoccomment": {
@@ -1474,17 +1475,13 @@
14741475 }
14751476 },
14761477 "node_modules/@jridgewell/gen-mapping": {
14771478 "version": "0.3.813",
14781479 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.813.tgz",
14791480 "integrity": "sha512-imAbBGkb2kkt/7niJ6MgEPxF0bYdQ6etZaA+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWAfQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
14801481 "license": "MIT",
14811482 "dependencies": {
14821483 "@jridgewell/setsourcemap-arraycodec": "^1.25.10",
1483- "@jridgewell/sourcemap-codec": "^1.4.10",
14841484 "@jridgewell/trace-mapping": "^0.3.24"
1485- },
1486- "engines": {
1487- "node": ">=6.0.0"
14881485 }
14891486 },
14901487 "node_modules/@jridgewell/resolve-uri": {
@@ -1496,19 +1493,10 @@
14961493 "node": ">=6.0.0"
14971494 }
14981495 },
1499- "node_modules/@jridgewell/set-array": {
1500- "version": "1.2.1",
1501- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
1502- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
1503- "license": "MIT",
1504- "engines": {
1505- "node": ">=6.0.0"
1506- }
1507- },
15081496 "node_modules/@jridgewell/source-map": {
15091497 "version": "0.3.611",
15101498 "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.611.tgz",
15111499 "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGudZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWEA0G8V/tt+shMQXWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
15121500 "license": "MIT",
15131501 "dependencies": {
15141502 "@jridgewell/gen-mapping": "^0.3.5",
@@ -1516,15 +1504,15 @@
15161504 }
15171505 },
15181506 "node_modules/@jridgewell/sourcemap-codec": {
15191507 "version": "1.5.05",
15201508 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.05.tgz",
15211509 "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgfcYQ9310grqxueWbl+PwPaM7GQWuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
15221510 "license": "MIT"
15231511 },
15241512 "node_modules/@jridgewell/trace-mapping": {
15251513 "version": "0.3.2531",
15261514 "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.2531.tgz",
15271515 "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTFzzNR+8Lb57DwOb3Aa0o9CApepiYQSdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
15281516 "license": "MIT",
15291517 "dependencies": {
15301518 "@jridgewell/resolve-uri": "^3.1.0",
@@ -1921,9 +1909,9 @@
19211909 }
19221910 },
19231911 "node_modules/@types/estree": {
19241912 "version": "1.0.68",
19251913 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.68.tgz",
19261914 "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cEdWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+gHpnPyXjHWxcwJuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
19271915 "license": "MIT"
19281916 },
19291917 "node_modules/@types/express": {
@@ -2618,9 +2606,9 @@
26182606 }
26192607 },
26202608 "node_modules/acorn": {
26212609 "version": "8.1415.0",
26222610 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.1415.0.tgz",
26232611 "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7KNZyJarBfL7nWwIq+t0cXIrH5siy5S4XkFycAFDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
26242612 "license": "MIT",
26252613 "peer": true,
26262614 "bin": {
@@ -2630,6 +2618,18 @@
26302618 "node": ">=0.4.0"
26312619 }
26322620 },
2621+ "node_modules/acorn-import-phases": {
2622+ "version": "1.0.4",
2623+ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
2624+ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
2625+ "license": "MIT",
2626+ "engines": {
2627+ "node": ">=10.13.0"
2628+ },
2629+ "peerDependencies": {
2630+ "acorn": "^8.14.0"
2631+ }
2632+ },
26332633 "node_modules/acorn-jsx": {
26342634 "version": "5.3.2",
26352635 "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
@@ -2998,13 +2998,13 @@
29982998 }
29992999 },
30003000 "node_modules/axios": {
30013001 "version": "1.1213.05",
30023002 "resolved": "https://registry.npmjs.org/axios/-/axios-1.1213.05.tgz",
30033003 "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzVcz4ur7Vb0xS4/SPDEhMB3QKUN0tPWe44eqxrIu31me+DSurgfbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
30043004 "license": "MIT",
30053005 "dependencies": {
30063006 "follow-redirects": "^1.15.611",
30073007 "form-data": "^4.0.45",
30083008 "proxy-from-env": "^1.1.0"
30093009 }
30103010 },
@@ -3050,6 +3050,15 @@
30503050 ],
30513051 "license": "MIT"
30523052 },
3053+ "node_modules/baseline-browser-mapping": {
3054+ "version": "2.9.19",
3055+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
3056+ "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
3057+ "license": "Apache-2.0",
3058+ "bin": {
3059+ "baseline-browser-mapping": "dist/cli.js"
3060+ }
3061+ },
30533062 "node_modules/basic-ftp": {
30543063 "version": "5.0.5",
30553064 "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
@@ -3182,9 +3191,9 @@
31823191 }
31833192 },
31843193 "node_modules/browserslist": {
31853194 "version": "4.2428.01",
31863195 "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.2428.01.tgz",
31873196 "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIWZC5Bd0LgJXgwGqUknZY/cLM5HSJvkUQ04r8NXnJZ3yYi4vDmSiZmC/9k884ApdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
31883197 "funding": [
31893198 {
31903199 "type": "opencollective",
@@ -3202,10 +3211,11 @@
32023211 "license": "MIT",
32033212 "peer": true,
32043213 "dependencies": {
32053214 "caniusebaseline-litebrowser-mapping": "^12.09.300016630",
32063215 "electron-tocaniuse-chromiumlite": "^1.50.2830001759",
32073216 "nodeelectron-releasesto-chromium": "^21.05.18263",
32083217 "update-browserslistnode-dbreleases": "^1.12.0.27",
3218+ "update-browserslist-db": "^1.2.0"
32093219 },
32103220 "bin": {
32113221 "browserslist": "cli.js"
@@ -3364,9 +3374,9 @@
33643374 }
33653375 },
33663376 "node_modules/caniuse-lite": {
33673377 "version": "1.0.3000166930001768",
33683378 "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.3000166930001768.tgz",
33693379 "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4qY3aDRZC5nWPgHUgIB84WL+9HrLaYei4w8BIAL7IBnySuo19wk0VJpp/UEDu889d8vhCTPA0wXI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==",
33703380 "funding": [
33713381 {
33723382 "type": "opencollective",
@@ -3452,17 +3462,17 @@
34523462 }
34533463 },
34543464 "node_modules/chevrotain": {
34553465 "version": "11.1.01",
34563466 "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.01.tgz",
34573467 "integrity": "sha512-BqwSf3RDQlHQ+EyWqTLDd23IwJ3clav6QyNQM4FNj0RF2f0yv5CPKaFxfsPTBzX7vGuim4oIC1/HfXESPjrApKkEstV5jbyJtUB8U4zrUFdLd2Cx1oAgcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==",
34583468 "license": "Apache-2.0",
34593469 "dependencies": {
34603470 "@chevrotain/cst-dts-gen": "11.1.01",
34613471 "@chevrotain/gast": "11.1.01",
34623472 "@chevrotain/regexp-to-ast": "11.1.01",
34633473 "@chevrotain/types": "11.1.01",
34643474 "@chevrotain/utils": "11.1.01",
34653475 "lodash-es": "4.17.2123"
34663476 }
34673477 },
34683478 "node_modules/chrome-trace-event": {
@@ -4311,9 +4321,9 @@
43114321 "license": "MIT"
43124322 },
43134323 "node_modules/electron-to-chromium": {
43144324 "version": "1.5.39286",
43154325 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.39286.tgz",
43164326 "integrity": "sha512-4xkpSR6CjuiaNyvwiWDI85N9AxsvbPawB8xc7yzLPonYTuP19BVgYweKyUMFtHEZgIcHWMt1ks5Cqx2m9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+6bhLThdB+plgMeou98CAaHu/GrgWATj2iHOOHTp1hWtABj2A==",
43174327 "license": "ISC"
43184328 },
43194329 "node_modules/emoji-regex": {
@@ -4341,13 +4351,13 @@
43414351 }
43424352 },
43434353 "node_modules/enhanced-resolve": {
43444354 "version": "5.1719.10",
43454355 "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.1719.10.tgz",
43464356 "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQphv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwygAbf4g187lUUAvH+H26omrqia2aGg==",
43474357 "license": "MIT",
43484358 "dependencies": {
43494359 "graceful-fs": "^4.2.4",
43504360 "tapable": "^2.23.0"
43514361 },
43524362 "engines": {
43534363 "node": ">=10.13.0"
@@ -4402,6 +4412,7 @@
44024412 "version": "1.5.4",
44034413 "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
44044414 "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
4415+ "dev": true,
44054416 "license": "MIT"
44064417 },
44074418 "node_modules/es-object-atoms": {
@@ -4964,9 +4975,9 @@
49644975 "license": "MIT"
49654976 },
49664977 "node_modules/fast-uri": {
49674978 "version": "3.01.60",
49684979 "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.01.60.tgz",
49694980 "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHwiPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
49704981 "funding": [
49714982 {
49724983 "type": "github",
@@ -5119,15 +5130,16 @@
51195130 "license": "ISC"
51205131 },
51215132 "node_modules/follow-redirects": {
51225133 "version": "1.15.611",
51235134 "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.611.tgz",
51245135 "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpWdeG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
51255136 "funding": [
51265137 {
51275138 "type": "individual",
51285139 "url": "https://github.com/sponsors/RubenVerborgh"
51295140 }
51305141 ],
5142+ "license": "MIT",
51315143 "engines": {
51325144 "node": ">=4.0"
51335145 },
@@ -5153,9 +5165,9 @@
51535165 }
51545166 },
51555167 "node_modules/form-data": {
51565168 "version": "4.0.45",
51575169 "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.45.tgz",
51585170 "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+4IlGTMF0OwwuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
51595171 "license": "MIT",
51605172 "dependencies": {
51615173 "asynckit": "^0.4.0",
@@ -5836,6 +5848,18 @@
58365848 "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
58375849 "license": "MIT"
58385850 },
5851+ "node_modules/image-size": {
5852+ "version": "2.0.2",
5853+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
5854+ "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
5855+ "license": "MIT",
5856+ "bin": {
5857+ "image-size": "bin/image-size.js"
5858+ },
5859+ "engines": {
5860+ "node": ">=16.x"
5861+ }
5862+ },
58395863 "node_modules/immediate": {
58405864 "version": "3.0.6",
58415865 "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -6338,12 +6362,16 @@
63386362 }
63396363 },
63406364 "node_modules/loader-runner": {
63416365 "version": "4.3.01",
63426366 "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.01.tgz",
63436367 "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJVIWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/oGJXo8qCatFGTfDbY6W6ipGOYXfgKjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
63446368 "license": "MIT",
63456369 "engines": {
63466370 "node": ">=6.11.5"
6371+ },
6372+ "funding": {
6373+ "type": "opencollective",
6374+ "url": "https://opencollective.com/webpack"
63476375 }
63486376 },
63496377 "node_modules/localforage": {
@@ -6732,10 +6760,37 @@
67326760 "node": ">=10.12.0"
67336761 }
67346762 },
6763+ "node_modules/node-persist/node_modules/p-limit": {
6764+ "version": "3.1.0",
6765+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
6766+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
6767+ "license": "MIT",
6768+ "dependencies": {
6769+ "yocto-queue": "^0.1.0"
6770+ },
6771+ "engines": {
6772+ "node": ">=10"
6773+ },
6774+ "funding": {
6775+ "url": "https://github.com/sponsors/sindresorhus"
6776+ }
6777+ },
6778+ "node_modules/node-persist/node_modules/yocto-queue": {
6779+ "version": "0.1.0",
6780+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
6781+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
6782+ "license": "MIT",
6783+ "engines": {
6784+ "node": ">=10"
6785+ },
6786+ "funding": {
6787+ "url": "https://github.com/sponsors/sindresorhus"
6788+ }
6789+ },
67356790 "node_modules/node-releases": {
67366791 "version": "2.0.1827",
67376792 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1827.tgz",
67386793 "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpLnmh3lCkYZ3grZvqcCH+eWPooLIfjmQ7X+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvImH0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+tIoVOdodFS40gNDLCwm2iorIlA==",
67396794 "license": "MIT"
67406795 },
67416796 "node_modules/normalize-path": {
@@ -6941,10 +6996,27 @@
69416996 "node": ">=8"
69426997 }
69436998 },
69446999 "node_modules/p-limitlocate": {
7000+ "version": "5.0.0",
7001+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
7002+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
7003+ "dev": true,
7004+ "license": "MIT",
7005+ "dependencies": {
7006+ "p-limit": "^3.0.2"
7007+ },
7008+ "engines": {
7009+ "node": ">=10"
7010+ },
7011+ "funding": {
7012+ "url": "https://github.com/sponsors/sindresorhus"
7013+ }
7014+ },
7015+ "node_modules/p-locate/node_modules/p-limit": {
69457016 "version": "3.1.0",
69467017 "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
69477018 "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
7019+ "dev": true,
69487020 "license": "MIT",
69497021 "dependencies": {
69507022 "yocto-queue": "^0.1.0"
@@ -6956,15 +7028,12 @@
69567028 "url": "https://github.com/sponsors/sindresorhus"
69577029 }
69587030 },
69597031 "node_modules/p-locate/node_modules/yocto-queue": {
69607032 "version": "5.0.1.0",
69617033 "resolved": "https://registry.npmjs.org/pyocto-locatequeue/-/pyocto-locatequeue-5.0.1.0.tgz",
69627034 "integrity": "sha512-LaNjtRWUBY++zB5nErVksvsnNCdJ/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7XohGc6xgPwyN8eheCxsiLM8mxuE/tlt/QYq3TIeE6nxHppbo2LGymrG5PwmOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
69637035 "dev": true,
69647036 "license": "MIT",
6965- "dependencies": {
6966- "p-limit": "^3.0.2"
6967- },
69687037 "engines": {
69697038 "node": ">=10"
69707039 },
@@ -7221,9 +7290,9 @@
72217290 }
72227291 },
72237292 "node_modules/picocolors": {
72247293 "version": "1.1.01",
72257294 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.01.tgz",
72267295 "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzyxceH2snhtb5M9liqDsmEw56le376mTZkEX/kWr8lkdjEb/hp3mTg7wYK7zJhuBStmGMBG0BdeDZSRxNFyegNul7eNslCXP9FDj/dZx1IukaX6Bk11zcln25o1AwLcu0X8KEyMceP2ntpaHrDEVA==",
72277296 "license": "ISC"
72287297 },
72297298 "node_modules/picomatch": {
@@ -7419,9 +7488,9 @@
74197488 }
74207489 },
74217490 "node_modules/qs": {
74227491 "version": "6.14.12",
74237492 "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.12.tgz",
74247493 "integrity": "sha512-4EK3+xJl8Ts67nLYNwqwV/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQyCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
74257494 "license": "BSD-3-Clause",
74267495 "dependencies": {
74277496 "side-channel": "^1.1.0"
@@ -7811,9 +7880,9 @@
78117880 "license": "ISC"
78127881 },
78137882 "node_modules/schema-utils": {
78147883 "version": "4.3.23",
78157884 "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.23.tgz",
78167885 "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19ueflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQOUYdb48v4k4WWHQurA==",
78177886 "license": "MIT",
78187887 "dependencies": {
78197888 "@types/json-schema": "^7.0.9",
@@ -8447,12 +8516,16 @@
84478516 }
84488517 },
84498518 "node_modules/tapable": {
84508519 "version": "2.23.10",
84518520 "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.23.10.tgz",
84528521 "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCpg9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+kqaQQaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
84538522 "license": "MIT",
84548523 "engines": {
84558524 "node": ">=6"
8525+ },
8526+ "funding": {
8527+ "type": "opencollective",
8528+ "url": "https://opencollective.com/webpack"
84568529 }
84578530 },
84588531 "node_modules/tar-stream": {
@@ -8466,13 +8539,13 @@
84668539 }
84678540 },
84688541 "node_modules/terser": {
84698542 "version": "5.3946.0",
84708543 "resolved": "https://registry.npmjs.org/terser/-/terser-5.3946.0.tgz",
84718544 "integrity": "sha512-LBAhFyLho16harJoWMgjTwoImyr/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWwQbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
84728545 "license": "BSD-2-Clause",
84738546 "dependencies": {
84748547 "@jridgewell/source-map": "^0.3.3",
84758548 "acorn": "^8.815.20",
84768549 "commander": "^2.20.0",
84778550 "source-map-support": "~0.5.20"
84788551 },
@@ -8484,9 +8557,9 @@
84848557 }
84858558 },
84868559 "node_modules/terser-webpack-plugin": {
84878560 "version": "5.3.1216",
84888561 "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1216.tgz",
84898562 "integrity": "sha512-jDLYqo7oF8tJIttjXO6jBY5Hk8p3A8W4ttih7cCEq64fQFWmgJ4VqAQjKr7WwIDlmXKEc6QeoRb5ecjZh9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+2afcgCsUZYmYEIZ3mR+Q==",
84908563 "license": "MIT",
84918564 "dependencies": {
84928565 "@jridgewell/trace-mapping": "^0.3.25",
@@ -8740,9 +8813,9 @@
87408813 }
87418814 },
87428815 "node_modules/update-browserslist-db": {
87438816 "version": "1.12.13",
87448817 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.12.13.tgz",
87458818 "integrity": "sha512-R8UzCaa9AzJs0m9cx+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5qOgDxo0eMiFGEueWztz+lo5r94l29Ad4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
87468819 "funding": [
87478820 {
87488821 "type": "opencollective",
@@ -8760,7 +8833,7 @@
87608833 "license": "MIT",
87618834 "dependencies": {
87628835 "escalade": "^3.2.0",
87638836 "picocolors": "^1.1.01"
87648837 },
87658838 "bin": {
87668839 "update-browserslist-db": "cli.js"
@@ -8865,9 +8938,9 @@
88658938 "license": "Apache-2.0"
88668939 },
88678940 "node_modules/watchpack": {
88688941 "version": "2.45.21",
88698942 "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.45.21.tgz",
88708943 "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJwZn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
88718944 "license": "MIT",
88728945 "dependencies": {
88738946 "glob-to-regexp": "^0.4.1",
@@ -8908,34 +8981,36 @@
89088981 }
89098982 },
89108983 "node_modules/webpack": {
89118984 "version": "5.98105.0",
89128985 "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98105.0.tgz",
89138986 "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJgX/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXAdMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==",
89148987 "license": "MIT",
89158988 "dependencies": {
89168989 "@types/eslint-scope": "^3.7.7",
89178990 "@types/estree": "^1.0.68",
8991+ "@types/json-schema": "^7.0.15",
89188992 "@webassemblyjs/ast": "^1.14.1",
89198993 "@webassemblyjs/wasm-edit": "^1.14.1",
89208994 "@webassemblyjs/wasm-parser": "^1.14.1",
89218995 "acorn": "^8.1415.0",
89228996 "browserslistacorn-import-phases": "^4.241.0.3",
8997+ "browserslist": "^4.28.1",
89238998 "chrome-trace-event": "^1.0.2",
89248999 "enhanced-resolve": "^5.1719.10",
89259000 "es-module-lexer": "^1.2.10.0",
89269001 "eslint-scope": "5.1.1",
89279002 "events": "^3.2.0",
89289003 "glob-to-regexp": "^0.4.1",
89299004 "graceful-fs": "^4.2.11",
89309005 "json-parse-even-better-errors": "^2.3.1",
89319006 "loader-runner": "^4.23.01",
89329007 "mime-types": "^2.1.27",
89339008 "neo-async": "^2.6.2",
89349009 "schema-utils": "^4.3.03",
89359010 "tapable": "^2.13.10",
89369011 "terser-webpack-plugin": "^5.3.1116",
89379012 "watchpack": "^2.45.1",
89389013 "webpack-sources": "^3.23.3"
89399014 },
89409015 "bin": {
89419016 "webpack": "bin/webpack.js"
@@ -8954,14 +9029,20 @@
89549029 }
89559030 },
89569031 "node_modules/webpack-sources": {
89579032 "version": "3.23.3",
89589033 "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.23.3.tgz",
89599034 "integrity": "sha512-/DyMEOrDgLKKIG0fmvtzyd1RBzSGanHkitROoPFd6qsrxt+4dUXoFhg/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
89609035 "license": "MIT",
89619036 "engines": {
89629037 "node": ">=10.13.0"
89639038 }
89649039 },
9040+ "node_modules/webpack/node_modules/es-module-lexer": {
9041+ "version": "2.0.0",
9042+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
9043+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
9044+ "license": "MIT"
9045+ },
89659046 "node_modules/webpack/node_modules/eslint-scope": {
89669047 "version": "5.1.1",
89679048 "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -9236,18 +9317,6 @@
92369317 "node": ">=12"
92379318 }
92389319 },
9239- "node_modules/yocto-queue": {
9240- "version": "0.1.0",
9241- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
9242- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
9243- "license": "MIT",
9244- "engines": {
9245- "node": ">=10"
9246- },
9247- "funding": {
9248- "url": "https://github.com/sponsors/sindresorhus"
9249- }
9250- },
92519320 "node_modules/zip-stream": {
92529321 "version": "6.0.1",
92539322 "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
package.json+3 -8
@@ -35,7 +35,7 @@
3535 "bowser": "^2.12.1",
3636 "bytes": "^3.1.2",
3737 "chalk": "^5.6.0",
3838 "chevrotain": "^11.01.31",
3939 "command-exists": "^1.2.9",
4040 "compression": "^1.8.1",
4141 "cookie-parser": "^1.4.6",
@@ -57,6 +57,7 @@
5757 "host-validation-middleware": "^0.1.1",
5858 "html-entities": "^2.6.0",
5959 "iconv-lite": "^0.6.3",
60+ "image-size": "^2.0.2",
6061 "ip-matching": "^2.1.2",
6162 "ip-regex": "^5.0.0",
6263 "ipaddr.js": "^2.2.0",
@@ -99,14 +100,8 @@
99100 "vectra": {
100101 "openai": "^4.17.0"
101102 },
102- "axios": {
103- "follow-redirects": "^1.15.4"
104- },
105103 "node-fetch": {
106104 "whatwg-url": "^14.0.0"
107- },
108- "chevrotain": {
109- "lodash-es": "^4.17.23"
110105 }
111106 },
112107 "name": "sillytavern",
@@ -116,7 +111,7 @@
116111 "type": "git",
117112 "url": "https://github.com/SillyTavern/SillyTavern.git"
118113 },
119114 "version": "1.1516.0",
120115 "scripts": {
121116 "start": "node server.js",
122117 "debug": "node --inspect server.js",
public/css/backgrounds.css+6 -0
@@ -96,6 +96,12 @@
9696 font-size: calc(var(--mainFontSize) * 0.95);
9797}
9898
99+#bg-sort {
100+ width: auto;
101+ max-width: 6em;
102+ flex-shrink: 0;
103+}
104+
99105/* Thumbnails */
100106.bg_example:hover .BGSampleTitle {
101107 opacity: 1;
public/css/extensions-panel.css+6 -2
@@ -39,7 +39,7 @@ label[for="extensions_autoconnect"] {
3939 text-align: left;
4040}
4141
4242.extensions_info h3:not(.margin0) {
4343 margin-bottom: 0.5em;
4444}
4545
@@ -112,6 +112,10 @@ label[for="extensions_autoconnect"] {
112112 color: limegreen;
113113}
114114
115+.extensions_info .third_party_toolbar {
116+ user-select: none;
117+}
118+
115119input.extension_missing[type="checkbox"] {
116120 opacity: 0.5;
117121}
@@ -157,4 +161,4 @@ input.extension_missing[type="checkbox"] {
157161 z-index: 1;
158162 margin-bottom: 10px;
159163 padding: 5px;
160164}
164 \ No newline at end of file
public/css/macros.css+88 -0
@@ -465,6 +465,89 @@
465465 color: #F89406;
466466}
467467
468+/* Arity warning banner in details */
469+.macro-ac-warning {
470+ display: flex;
471+ align-items: baseline;
472+ gap: 0.5em;
473+ padding: 0.5em 0.75em;
474+ background: linear-gradient(90deg, rgba(248, 148, 6, 0.2), transparent);
475+ border-left: 3px solid #F89406;
476+ border-radius: 0 4px 4px 0;
477+ margin-bottom: 0.5em;
478+ font-size: 0.9em;
479+ color: #F89406;
480+}
481+
482+.macro-ac-warning i {
483+ font-size: 0.9em;
484+}
485+
486+/* Scoped content info banner in details */
487+.macro-ac-scoped-info {
488+ display: flex;
489+ align-items: baseline;
490+ gap: 0.5em;
491+ padding: 0.5em 0.75em;
492+ background: linear-gradient(90deg, rgba(91, 192, 222, 0.2), transparent);
493+ border-left: 3px solid #5BC0DE;
494+ border-radius: 0 4px 4px 0;
495+ margin-bottom: 0.5em;
496+ font-size: 0.9em;
497+ color: #5BC0DE;
498+}
499+
500+.macro-ac-scoped-info i {
501+ font-size: 0.9em;
502+}
503+
504+.macro-ac-scoped-info code {
505+ background: rgba(91, 192, 222, 0.15);
506+ padding: 0.1em 0.3em;
507+ border-radius: 3px;
508+}
509+
510+/* OPTIONAL badge for optional scoped content */
511+.macro-ac-optional-badge {
512+ display: inline-block;
513+ background: linear-gradient(135deg, #f0ad4e, #ec971f);
514+ color: #000;
515+ font-weight: bold;
516+ font-size: 0.75em;
517+ padding: 0.15em 0.5em;
518+ border-radius: 3px;
519+ text-transform: uppercase;
520+ letter-spacing: 0.05em;
521+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
522+}
523+
524+/* Smaller variant for use in autocomplete list items */
525+.macro-ac-optional-badge-small {
526+ font-size: 0.65em;
527+ padding: 0.1em 0.35em;
528+ vertical-align: middle;
529+ color: #f0ad4e;
530+}
531+
532+/* Closing tag autocomplete option */
533+.autoComplete > .item.macro-closing-tag-item > .type {
534+ color: var(--ac-color-matchedText, var(--SmartThemeBorderColor));
535+}
536+
537+.macro-closing-tag-details {
538+ padding: 0.5em;
539+}
540+
541+.macro-closing-tag-details h3 {
542+ margin: 0 0 0.5em 0;
543+ font-size: 1.1em;
544+}
545+
546+.macro-closing-tag-details p {
547+ margin: 0;
548+ opacity: 0.9;
549+}
550+
468551/* Current argument hint banner in details */
469552.macro-ac-arg-hint {
470553 display: flex;
@@ -483,6 +566,11 @@
483566 font-size: 0.8em;
484567}
485568
569+.macro-ac-arg-hint .macro-ac-arg-hint-small {
570+ font-size: 0.85em;
571+ opacity: 0.8;
572+}
573+
486574.macro-ac-hint-type {
487575 font-family: var(--monoFontFamily);
488576 font-size: 0.85em;
public/css/tags.css+4 -0
@@ -67,6 +67,10 @@
6767 display: none;
6868}
6969
70+.tag.tag-absent {
71+ text-decoration: line-through;
72+}
73+
7074.tag.actionable {
7175 border-radius: 50%;
7276 aspect-ratio: 1 / 1;
public/css/toggle-dependent.css+4 -0
@@ -564,3 +564,7 @@ label[for="bind_preset_to_connection"]:has(input:checked) {
564564#request_images_block:has(#openai_request_images:not(:checked)) #request_images_settings {
565565 display: none;
566566}
567+
568+#adaptive_p_block:has([data-tg-samplers="adaptive_target"][style*="display: none"]):has([data-tg-samplers="adaptive_decay"][style*="display: none"]) {
569+ display: none;
570+}
public/css/welcome.css+9 -0
@@ -115,6 +115,7 @@ body.hideChatAvatars .welcomePanel .recentChatList .recentChat .avatar {
115115 cursor: pointer;
116116 gap: 10px;
117117 border: 1px solid var(--SmartThemeBorderColor);
118+ position: relative;
118119}
119120
120121.welcomeRecent .recentChatList .recentChat .avatar {
@@ -222,6 +223,14 @@ body.big-avatars .welcomeRecent .recentChatList .recentChat .chatMessageContaine
222223 transform: rotate(180deg);
223224}
224225
226+.welcomeRecent .recentChatList .recentChat .recentChatPinned {
227+ top: 1px;
228+ left: 1px;
229+ position: absolute;
230+ opacity: 0.8;
231+ color: var(--SmartThemeQuoteColor);
232+}
233+
225234@media screen and (max-width: 1000px) {
226235 .welcomePanel .welcomeShortcuts a span {
227236 display: none;
public/global.d.ts+9 -0
@@ -38,6 +38,7 @@ declare global {
3838 avatar_url?: string;
3939 hideMutedSprites?: boolean;
4040 fav?: boolean;
41+ date_last_chat?: MessageTimestamp;
4142 }
4243
4344 interface ChatFile extends Array<ChatMessage> {
@@ -235,3 +236,11 @@ declare global {
235236
236237 type SwipeEvent = JQuery.TriggeredEvent<any, any, HTMLElement, HTMLElement>;
237238}
239+
240+//Overrides for public/scripts/chats.js
241+declare module 'dompurify' {
242+ interface Config {
243+ MESSAGE_SANITIZE?: boolean;
244+ MESSAGE_ALLOW_SYSTEM_UI?: boolean;
245+ }
246+}
public/index.html+139 -62
@@ -1399,6 +1399,28 @@
13991399 <input class="neo-range-slider" type="range" id="max_tokens_second_textgenerationwebui" name="volume" min="0" max="20" step="1" />
14001400 <input class="neo-range-input" type="number" min="0" max="20" step="1" data-for="max_tokens_second_textgenerationwebui" id="max_tokens_second_counter_textgenerationwebui">
14011401 </div>
1402+
1403+ <div data-tg-type="koboldcpp, llamacpp, tabby" id="adaptive_p_block" class="wide100p">
1404+ <h4 class="wide100p textAlignCenter">
1405+ <label data-i18n="Adaptive-P">Adaptive-P</label>
1406+ <a href="https://github.com/MrJackSpade/adaptive-p-docs" target="_blank">
1407+ <div class="fa-solid fa-circle-info opacity50p"></div>
1408+ </a>
1409+ </h4>
1410+ <div class="flex-container flexFlowRow alignitemscenter gap10px flexShrink">
1411+ <div data-tg-samplers="adaptive_target" class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
1412+ <small data-i18n="Target">Target</small>
1413+ <input class="neo-range-slider" type="range" id="adaptive_target_textgenerationwebui" min="-0.01" max="1" step="0.01" />
1414+ <input class="neo-range-input" type="number" min="-0.01" max="1" step="0.01" data-for="adaptive_target_textgenerationwebui" id="adaptive_target_counter_textgenerationwebui">
1415+ </div>
1416+ <div data-tg-samplers="adaptive_decay" class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
1417+ <small data-i18n="Decay">Decay</small>
1418+ <input class="neo-range-slider" type="range" id="adaptive_decay_textgenerationwebui" min="0" max="0.99" step="0.01" />
1419+ <input class="neo-range-input" type="number" min="0" max="0.99" step="0.01" data-for="adaptive_decay_textgenerationwebui" id="adaptive_decay_counter_textgenerationwebui">
1420+ </div>
1421+ </div>
1422+ </div>
1423+
14021424 <div data-tg-type="mancer, ooba, koboldcpp, aphrodite, tabby" data-tg-samplers="smoothing_factor" id="smoothingBlock" name="smoothingBlock" class="wide100p">
14031425 <h4 class="wide100p textAlignCenter">
14041426 <label data-i18n="Smooth Sampling">Smooth Sampling</label>
@@ -1974,7 +1996,7 @@
19741996 </b>
19751997 </div>
19761998 </div>
19771999 <div class="range-block" data-source="openai,cohere,mistralai,custom,claude,aimlapi,openrouter,groq,siliconflow,deepseek,makersuite,vertexai,ai21,xai,pollinations,moonshot,fireworks,cometapi,electronhub,chutes,azure_openai,zai,nanogpt">
19782000 <label for="openai_function_calling" class="checkbox_label flexWrap widthFreeExpand">
19792001 <input id="openai_function_calling" type="checkbox" />
19802002 <span data-i18n="Enable function calling">Enable function calling</span>
@@ -2002,7 +2024,7 @@
20022024 <i class="icon-supported fa-solid fa-film" title="Supported by the current model" data-i18n="[title]Supported by the current model"></i>
20032025 <i class="icon-unsupported fa-solid fa-film" title="Unsupported by the current model" data-i18n="[title]Unsupported by the current model"></i>
20042026 </div>
20052027 <div id="openai_audio_inlining_supported" data-source="makersuite,vertexai,openrouter,openai,custom">
20062028 <i class="icon-supported fa-solid fa-music" title="Supported by the current model" data-i18n="[title]Supported by the current model"></i>
20072029 <i class="icon-unsupported fa-solid fa-music" title="Unsupported by the current model" data-i18n="[title]Unsupported by the current model"></i>
20082030 </div>
@@ -2083,12 +2105,12 @@
20832105 <span data-i18n="Allows the model to return its thinking process.">
20842106 Allows the model to return its thinking process.
20852107 </span>
20862108 <strong data-i18n="This setting affects visibility only." data-source-mode="except" data-source="zai,moonshot">
20872109 This setting affects visibility only.
20882110 </strong>
20892111 </div>
20902112 </div>
20912113 <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom,claude,xai,makersuite,vertexai,aimlapi,openrouter,pollinations,perplexity,cometapi,electronhub,azure_openai,chutes,nanogpt">
20922114 <div class="flex-container oneline-dropdown" title="Constrains effort on reasoning for reasoning models.&#10;Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response." data-i18n="[title]Constrains effort on reasoning for reasoning models.">
20932115 <label for="openai_reasoning_effort">
20942116 <span data-i18n="Reasoning Effort">Reasoning Effort</span>
@@ -2419,6 +2441,20 @@
24192441 <span data-i18n="Allow fallback providers">Allow fallback providers</span>
24202442 </label>
24212443 </div>
2444+ <div>
2445+ <h4 data-i18n="Model Quantizations">Model Quantizations</h4>
2446+ <select id="openrouter_quantizations_text" class="openrouter_quantizations" multiple>
2447+ <option data-i18n="Integer (4 bit)" value="int4">Integer (4 bit)</option>
2448+ <option data-i18n="Integer (8 bit)" value="int8">Integer (8 bit)</option>
2449+ <option data-i18n="Floating point (4 bit)" value="fp4">Floating point (4 bit)</option>
2450+ <option data-i18n="Floating point (6 bit)" value="fp6">Floating point (6 bit)</option>
2451+ <option data-i18n="Floating point (8 bit)" value="fp8">Floating point (8 bit)</option>
2452+ <option data-i18n="Floating point (16 bit)" value="fp16">Floating point (16 bit)</option>
2453+ <option data-i18n="Brain floating point (16 bit)" value="bf16">Brain floating point (16 bit)</option>
2454+ <option data-i18n="Floating point (32 bit)" value="fp32">Floating point (32 bit)</option>
2455+ <option data-i18n="Unknown" value="unknown">Unknown</option>
2456+ </select>
2457+ </div>
24222458 </div>
24232459 <div data-tg-type="infermaticai" class="flex-container flexFlowColumn">
24242460 <h4 data-i18n="InfermaticAI API Key">InfermaticAI API Key</h4>
@@ -2845,7 +2881,7 @@
28452881 <option value="zai">Z.AI (GLM)</option>
28462882 </optgroup>
28472883 </select>
28482884 <div class="inline-drawer wide100p" data-source="openai,claude,mistralai,makersuite,vertexai,deepseek,xai,zai,moonshot">
28492885 <div class="inline-drawer-toggle inline-drawer-header">
28502886 <b data-i18n="Reverse Proxy">Reverse Proxy</b>
28512887 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
@@ -2909,7 +2945,7 @@
29092945 </div>
29102946 </div>
29112947 </div>
29122948 <div id="ReverseProxyWarningMessage" data-source="openai,claude,mistralai,makersuite,vertexai,deepseek,xai,zai,moonshot">
29132949 <div class="reverse_proxy_warning">
29142950 <b>
29152951 <div data-i18n="Using a proxy that you're not running yourself is a risk to your data privacy.">
@@ -3068,6 +3104,7 @@
30683104 <h4 data-i18n="Claude Model">Claude Model</h4>
30693105 <select id="model_claude_select">
30703106 <optgroup label="Versions">
3107+ <option value="claude-opus-4-6">claude-opus-4-6</option>
30713108 <option value="claude-opus-4-5">claude-opus-4-5</option>
30723109 <option value="claude-opus-4-5-20251101">claude-opus-4-5-20251101</option>
30733110 <option value="claude-sonnet-4-5">claude-sonnet-4-5</option>
@@ -3161,6 +3198,20 @@
31613198 <i class="fa-solid fa-lightbulb"></i>
31623199 <span data-i18n="To use instruct formatting, switch to OpenRouter under Text Completion API.">To use instruct formatting, switch to OpenRouter under Text Completion API.</span>
31633200 </small>
3201+ <div>
3202+ <h4 data-i18n="Model Quantizations">Model Quantizations</h4>
3203+ <select id="openrouter_quantizations_chat" class="openrouter_quantizations" multiple>
3204+ <option data-i18n="Integer (4 bit)" value="int4">Integer (4 bit)</option>
3205+ <option data-i18n="Integer (8 bit)" value="int8">Integer (8 bit)</option>
3206+ <option data-i18n="Floating point (4 bit)" value="fp4">Floating point (4 bit)</option>
3207+ <option data-i18n="Floating point (6 bit)" value="fp6">Floating point (6 bit)</option>
3208+ <option data-i18n="Floating point (8 bit)" value="fp8">Floating point (8 bit)</option>
3209+ <option data-i18n="Floating point (16 bit)" value="fp16">Floating point (16 bit)</option>
3210+ <option data-i18n="Brain floating point (16 bit)" value="bf16">Brain floating point (16 bit)</option>
3211+ <option data-i18n="Floating point (32 bit)" value="fp32">Floating point (32 bit)</option>
3212+ <option data-i18n="Unknown" value="unknown">Unknown</option>
3213+ </select>
3214+ </div>
31643215 </form>
31653216 <form id="ai21_form" data-source="ai21" action="javascript:void(null);" method="post" enctype="multipart/form-data">
31663217 <h4 data-i18n="AI21 API Key">AI21 API Key</h4>
@@ -3750,19 +3801,22 @@
37503801 </select>
37513802 </div>
37523803 <div id="pollinations_form" data-source="pollinations">
3804+ <h4>
3805+ <a href="https://enter.pollinations.ai/" target="_blank" rel="noopener noreferrer" data-i18n="Pollinations API Key">
3806+ Pollinations API Key
3807+ </a>
3808+ </h4>
3809+ <div class="flex-container">
3810+ <input id="api_key_pollinations" name="api_key_pollinations" class="text_pole flex1" value="" type="text" autocomplete="off">
3811+ <div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_pollinations"></div>
3812+ </div>
3813+ <div data-for="api_key_pollinations" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3814+ For privacy reasons, your API key will be hidden after you click 'Connect'.
3815+ </div>
37533816 <h4 data-i18n="Pollinations Model">Pollinations Model</h4>
37543817 <select id="model_pollinations_select">
37553818 <!-- Populated by JavaScript -->
37563819 </select>
3757- <div class="info-block hint">
3758- <a href="https://pollinations.ai/" target="_blank" rel="noopener noreferrer" data-i18n="Provided free of charge by Pollinations.AI">
3759- Provided free of charge by Pollinations.AI
3760- </a>
3761- <br>
3762- <span data-i18n="Avoid sending sensitive information. Provider's outputs may include ads.">
3763- Avoid sending sensitive information. Provider's outputs may include ads.
3764- </span>
3765- </div>
37663820 </div>
37673821 <div id="moonshot_form" data-source="moonshot">
37683822 <h4>
@@ -3811,7 +3865,10 @@
38113865 </select>
38123866 <h4 data-i18n="Z.AI Model">Z.AI Model</h4>
38133867 <select id="model_zai_select">
3868+ <option value="glm-5">glm-5</option>
38143869 <option value="glm-4.7">glm-4.7</option>
3870+ <option value="glm-4.7-flash">glm-4.7-flash</option>
3871+ <option value="glm-4.7-flashx">glm-4.7-flashx</option>
38153872 <option value="glm-4.6">glm-4.6</option>
38163873 <option value="glm-4.6v">glm-4.6v</option>
38173874 <option value="glm-4.6v-flash">glm-4.6v-flash</option>
@@ -3982,7 +4039,7 @@
39824039 <small data-i18n="Story String">Story String</small>
39834040 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="context_story_string" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
39844041 </label>
39854042 <textarea id="context_story_string" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
39864043 </div>
39874044 <div class="flex-container flexFlowColumn" data-cc-null>
39884045 <div id="context_story_string_position_block">
@@ -4019,7 +4076,7 @@
40194076 <small data-i18n="Example Separator">Example Separator</small>
40204077 </label>
40214078 <div>
40224079 <textarea id="context_example_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
40234080 </div>
40244081 </div>
40254082 <div class="flex1">
@@ -4027,7 +4084,7 @@
40274084 <small data-i18n="Chat Start">Chat Start</small>
40284085 </label>
40294086 <div>
40304087 <textarea id="context_chat_start" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
40314088 </div>
40324089 </div>
40334090 </div>
@@ -4191,11 +4248,11 @@
41914248 <div class="flex-container">
41924249 <div class="flexAuto" title="Inserted before a User message and as a last prompt line when impersonating." data-i18n="[title]Inserted before a User message and as a last prompt line when impersonating.">
41934250 <small data-i18n="User Prefix">User Message Prefix</small>
41944251 <textarea id="instruct_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
41954252 </div>
41964253 <div class="flexAuto" title="Inserted after a User message." data-i18n="[title]Inserted after a User message.">
41974254 <small data-i18n="User Suffix">User Message Suffix</small>
41984255 <textarea id="instruct_input_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
41994256 </div>
42004257 </div>
42014258 </details>
@@ -4204,11 +4261,11 @@
42044261 <div class="flex-container">
42054262 <div class="flexAuto" title="Inserted before an Assistant message and as a last prompt line when generating an AI reply." data-i18n="[title]Inserted before an Assistant message and as a last prompt line when generating an AI reply.">
42064263 <small data-i18n="Assistant Prefix">Assistant Message Prefix</small>
42074264 <textarea id="instruct_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42084265 </div>
42094266 <div class="flexAuto" title="Inserted after an Assistant message." data-i18n="[title]Inserted after an Assistant message.">
42104267 <small data-i18n="Assistant Suffix">Assistant Message Suffix</small>
42114268 <textarea id="instruct_output_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42124269 </div>
42134270 </div>
42144271 </details>
@@ -4217,11 +4274,11 @@
42174274 <div class="flex-container">
42184275 <div class="flexAuto" id="instruct_system_sequence_block" title="Inserted before a System (added by slash commands or extensions) message." data-i18n="[title]Inserted before a System (added by slash commands or extensions) message.">
42194276 <small data-i18n="System Prefix">System Message Prefix</small>
42204277 <textarea id="instruct_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42214278 </div>
42224279 <div class="flexAuto" id="instruct_system_suffix_block" title="Inserted after a System message." data-i18n="[title]Inserted after a System message.">
42234280 <small data-i18n="System Suffix">System Message Suffix</small>
42244281 <textarea id="instruct_system_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42254282 </div>
42264283 </div>
42274284 <div class="flexBasis100p" title="If enabled, System Sequences will be the same as User Sequences." data-i18n="[title]If enabled, System Sequences will be the same as User Sequences.">
@@ -4236,37 +4293,37 @@
42364293 <div class="flex-container">
42374294 <div class="flexAuto" title="Inserted before the first Assistant's message." data-i18n="[title]Inserted before the first Assistant's message.">
42384295 <small data-i18n="First Assistant Prefix">First Assistant Prefix</small>
42394296 <textarea id="instruct_first_output_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42404297 </div>
42414298 <div class="flexAuto" title="Inserted before the last Assistant's message or as a last prompt line when generating an AI reply (except a neutral/system role)." data-i18n="[title]instruct_last_output_sequence">
42424299 <small data-i18n="Last Assistant Prefix">Last Assistant Prefix</small>
42434300 <textarea id="instruct_last_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42444301 </div>
42454302 </div>
42464303 <div class="flex-container">
42474304 <div class="flexAuto" title="Inserted before the first User's message." data-i18n="[title]Inserted before the first User's message.">
42484305 <small data-i18n="First User Prefix">First User Prefix</small>
42494306 <textarea id="instruct_first_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42504307 </div>
42514308 <div class="flexAuto" title="Inserted before the last User's message." data-i18n="[title]instruct_last_input_sequence">
42524309 <small data-i18n="Last User Prefix">Last User Prefix</small>
42534310 <textarea id="instruct_last_input_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42544311 </div>
42554312 </div>
42564313 <div class="flex-container">
42574314 <div class="flexAuto" title="Will be inserted as a last prompt line when using system/neutral generation." data-i18n="[title]Will be inserted as a last prompt line when using system/neutral generation.">
42584315 <small data-i18n="System Instruction Prefix">System Instruction Prefix</small>
42594316 <textarea id="instruct_last_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42604317 </div>
42614318 <div class="flexAuto" title="If a stop sequence is generated, everything past it will be removed from the output (inclusive)." data-i18n="[title]If a stop sequence is generated, everything past it will be removed from the output (inclusive).">
42624319 <small data-i18n="Stop Sequence">Stop Sequence</small>
42634320 <textarea id="instruct_stop_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42644321 </div>
42654322 </div>
42664323 <div class="flex-container">
42674324 <div class="flexAuto" title="Will be inserted at the start of the chat history if it doesn't start with a User message." data-i18n="[title]Will be inserted at the start of the chat history if it doesn't start with a User message.">
42684325 <small data-i18n="User Filler Message">User Filler Message</small>
42694326 <textarea id="instruct_user_alignment_message" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42704327 </div>
42714328 </div>
42724329 </details>
@@ -4304,7 +4361,7 @@
43044361 <small data-i18n="Prompt Content">Prompt Content</small>
43054362 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="sysprompt_content" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
43064363 </label>
43074364 <textarea id="sysprompt_content" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
43084365 </div>
43094366
43104367 <div>
@@ -4312,7 +4369,7 @@
43124369 <small data-i18n="Post-History Instructions">Post-History Instructions</small>
43134370 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="sysprompt_post_history" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
43144371 </label>
43154372 <textarea id="sysprompt_post_history" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
43164373 </div>
43174374 </div>
43184375
@@ -4437,17 +4494,17 @@
44374494 <div class="flex-container">
44384495 <div class="flex1" title="Inserted before the reasoning content." data-i18n="[title]reasoning_prefix">
44394496 <small data-i18n="Prefix">Prefix</small>
44404497 <textarea id="reasoning_prefix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
44414498 </div>
44424499 <div class="flex1" title="Inserted after the reasoning content." data-i18n="[title]reasoning_suffix">
44434500 <small data-i18n="Suffix">Suffix</small>
44444501 <textarea id="reasoning_suffix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
44454502 </div>
44464503 </div>
44474504 <div class="flex-container">
44484505 <div class="flex1" title="Inserted between the reasoning and the message content." data-i18n="[title]reasoning_separator">
44494506 <small data-i18n="Separator">Separator</small>
44504507 <textarea id="reasoning_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
44514508 </div>
44524509 </div>
44534510 </details>
@@ -4469,7 +4526,7 @@
44694526 </span>
44704527 </small>
44714528 <div>
44724529 <input id="markdown_escape_strings" data-macros class="text_pole textarea_compact" type="text" data-i18n="[placeholder]comma delimited,no spaces between" placeholder="comma delimited,no spaces between" />
44734530 </div>
44744531 </div>
44754532
@@ -4481,7 +4538,7 @@
44814538 </span>
44824539 </small>
44834540 <div>
44844541 <textarea id="start_reply_with" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
44854542 </div>
44864543 <label class="checkbox_label" for="chat-show-reply-prefix-checkbox">
44874544 <input id="chat-show-reply-prefix-checkbox" type="checkbox" />
@@ -4780,7 +4837,7 @@
47804837 <div name="themeElements" class="flex-container flexFlowColumn flexNoGap">
47814838 <!-- <h4><span data-i18n="UI Colors">Theme Settings</span></h4> -->
47824839 <div name="AvatarAndChatDisplay" class="flex-container flexFlowColumn">
47834840 <div class="flex-container alignItemsBaseline" title="This style applies to all avatars globaly, including your Persona, Character ManagmentManagement, Account selection, etc." data-i18n="[title]This style applies to all avatars globaly, including your Persona, Character ManagmentManagement, Account selection, etc.">
47844841 <span data-i18n="Avatar Style:">Avatars:</span>
47854842 <select id="avatar_style" class="widthNatural flex1 margin0 text_pole">
47864843 <option value="0" data-i18n="Circle">Circle</option>
@@ -5362,20 +5419,28 @@
53625419 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
53635420 </div>
53645421 <div class="inline-drawer-content">
5365- <label for="stscript_autocomplete_state">
5422+ <div class="flex1" title="When to show the autocomplete for slash commands and macros." data-i18n="[title]When to show the autocomplete for slash commands and macros.">
53665423 <smalllabel data-i18nfor="Visibilitystscript_autocomplete_state">Visibility</small>
5367- </label>
5424+ <small data-i18n="Visibility">Visibility</small>
5368- <select id="stscript_autocomplete_state">
5425+ </label>
5369- <option value="0" data-i18n="Don't show">Don't show</option>
5426+ <select id="stscript_autocomplete_state">
53705427 <option value="10" data-i18n="Input length >Don't 1show">Input length >Don't 1show</option>
53715428 <option value="21" data-i18n="AlwaysInput showlength > 1">AlwaysInput showlength > 1</option>
5372- </select>
5429+ <option value="2" data-i18n="Always show">Always show</option>
5430+ </select>
5431+ </div>
53735432 <label class="checkbox_label" for="stscript_autocomplete_autoHide">
53745433 <input id="stscript_autocomplete_autoHide" type="checkbox" />
53755434 <small data-i18n="Automatically hide details">
53765435 Automatically hide details
53775436 </small>
53785437 </label>
5438+ <label class="checkbox_label" for="stscript_autocomplete_showInAllMacroFields" title="Show macro autocomplete in all macro-enabled fields. When off, autocomplete only shows in expanded editors or when pressing Ctrl+Space." data-i18n="[title]Show macro autocomplete in all macro-enabled fields. When off, autocomplete only shows in expanded editors or when pressing Ctrl+Space.">
5439+ <input id="stscript_autocomplete_showInAllMacroFields" type="checkbox" />
5440+ <small data-i18n="Show in all macro fields">
5441+ Show in all macro fields
5442+ </small>
5443+ </label>
53795444 <div class="flex-container">
53805445 <div class="flex1" title="Determines how entries are found for autocomplete." data-i18n="[title]Determines how entries are found for autocomplete.">
53815446 <label for="stscript_matching">
@@ -5499,6 +5564,12 @@
54995564 </div>
55005565 <div class="bg-header-row-2">
55015566 <input id="bg-filter" class="text_pole" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
5567+ <select id="bg-sort" class="text_pole margin0" title="Sort backgrounds" data-i18n="[title]Sort backgrounds">
5568+ <option value="az" data-i18n="A-Z">A-Z</option>
5569+ <option value="za" data-i18n="Z-A">Z-A</option>
5570+ <option value="newest" data-i18n="Newest">Newest</option>
5571+ <option value="oldest" data-i18n="Oldest">Oldest</option>
5572+ </select>
55025573 </div>
55035574 </div>
55045575 <div id="bg_tabs" class="heading-container-with-controls">
@@ -5695,7 +5766,7 @@
56955766 <span data-i18n="Persona Description">Persona Description</span>
56965767 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="persona_description" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
56975768 </h4>
56985769 <textarea id="persona_description" name="persona_description" data-macros data-i18n="[placeholder]Example: [{{user}} is a 28-year-old Romanian cat girl.]" placeholder="Example:&#10;[{{user}} is a 28-year-old Romanian cat girl.]" class="text_pole textarea_compact" value="" autocomplete="off" rows="8"></textarea>
56995770
57005771 <div class="flex-container justifySpaceBetween">
57015772 <h4 data-i18n="Position">Position</h4>
@@ -5954,7 +6025,7 @@
59546025 </span>
59556026 </div>
59566027 </div>
59576028 <textarea id="description_textarea" class="mdHotkeys" data-macros data-i18n="[placeholder]Describe your character's physical and mental traits here." placeholder="Describe your character's physical and mental traits here." name="description" placeholder=""></textarea>
59586029 <div class="extension_token_counter">
59596030 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="description_textarea" data-token-permanent="true">counting...</span>
59606031 </div>
@@ -5974,7 +6045,7 @@
59746045 </span>
59756046 </div>
59766047 </div>
59776048 <textarea classid="mdHotkeysfirstmessage_textarea" idclass="firstmessage_textareamdHotkeys" data-macros data-i18n="[placeholder]This will be the first message from the character that starts every chat." placeholder="This will be the first message from the character that starts every chat." name="first_mes" placeholder=""></textarea>
59786049 <div class="extension_token_counter">
59796050 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="firstmessage_textarea">counting...</span>
59806051 </div>
@@ -6104,6 +6175,12 @@
61046175 </div>
61056176 <div class="inline-drawer-content">
61066177 <div id="currentGroupMembers" name="Current Group Members" class="flex-container flexFlowColumn overflowYAuto flex1">
6178+ <div id="rm_group_members_header">
6179+ <input id="rm_group_members_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
6180+ </div>
6181+ <div class="rm_tag_controls">
6182+ <div class="tags rm_tag_filter"></div>
6183+ </div>
61076184 <div id="rm_group_members_pagination" class="rm_group_members_pagination group_pagination"></div>
61086185 <div id="rm_group_members" class="rm_group_members overflowYAuto flex-container" group_empty_text="Group is empty." data-i18n="[group_empty_text]Group is empty."></div>
61096186 </div>
@@ -6115,7 +6192,7 @@
61156192 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
61166193 </div>
61176194 <div class="inline-drawer-content">
61186195 <div id="unaddedCharList" name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1">
61196196 <div id="rm_group_add_members_header">
61206197 <input id="rm_group_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
61216198 </div>
@@ -6292,7 +6369,7 @@
62926369 <span data-i18n="Main Prompt">Main Prompt</span>
62936370 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="system_prompt_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
62946371 </h4>
62956372 <textarea id="system_prompt_textarea" name="system_prompt" data-macros data-i18n="[placeholder]Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)" placeholder="Any contents here will replace the default Main Prompt used for this character.&#10;(v2 spec: system_prompt)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea>
62966373 <div class="extension_token_counter">
62976374 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="system_prompt_textarea">counting...</span>
62986375 </div>
@@ -6302,7 +6379,7 @@
63026379 <span data-i18n="Post-History Instructions">Post-History Instructions</span>
63036380 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="post_history_instructions_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
63046381 </h4>
63056382 <textarea id="post_history_instructions_textarea" name="post_history_instructions" data-macros data-i18n="[placeholder]Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)" placeholder="Any contents here will replace the default Post-History Instructions used for this character.&#10;(v2 spec: post_history_instructions)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea>
63066383 <div class="extension_token_counter">
63076384 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="post_history_instructions_textarea">counting...</span>
63086385 </div>
@@ -6355,7 +6432,7 @@
63556432 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="personality_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
63566433 <a href="https://docs.sillytavern.app/usage/core-concepts/characterdesign/#personality-summary" class="notes-link" target="_blank"><span class="fa-solid fa-circle-question note-link-span"></span></a>
63576434 </h4>
63586435 <textarea id="personality_textarea" name="personality" data-macros data-i18n="[placeholder](A brief description of the personality)" placeholder="(A brief description of the personality)" form="form_create" class="text_pole" autocomplete="off" rows="4"></textarea>
63596436 <div class="extension_token_counter">
63606437 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="personality_textarea" data-token-permanent="true">counting...</span>
63616438 </div>
@@ -6368,7 +6445,7 @@
63686445 <span class="fa-solid fa-circle-question note-link-span"></span>
63696446 </a>
63706447 </h4>
63716448 <textarea id="scenario_pole" name="scenario" data-macros data-i18n="[placeholder](Circumstances and context of the interaction)" placeholder="(Circumstances and context of the interaction)" class="text_pole" value="" autocomplete="off" form="form_create" rows="4"></textarea>
63726449 <div class="extension_token_counter">
63736450 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="scenario_pole" data-token-permanent="true">counting...</span>
63746451 </div>
@@ -6381,7 +6458,7 @@
63816458 </span>
63826459 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="depth_prompt_prompt" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
63836460 </h4>
63846461 <textarea id="depth_prompt_prompt" name="depth_prompt_prompt" data-macros class="text_pole" rows="5" autocomplete="off" form="form_create" data-i18n="[placeholder](Text to be inserted in-chat @ designated depth and role)" placeholder="(Text to be inserted in-chat @ designated depth and role)"></textarea>
63856462 </div>
63866463 <div>
63876464 <h4>
@@ -6431,7 +6508,7 @@
64316508 </a>
64326509 </h5>
64336510 </div>
64346511 <textarea id="mes_example_textarea" class="flexGrow mdHotkeys" name="mes_example" data-macros data-i18n="[placeholder](Examples of chat dialog. Begin each example with START on a new line.)" placeholder="(Examples of chat dialog. Begin each example with &lt;START&gt; on a new line.)" form="form_create" rows="6"></textarea>
64356512 <div class="extension_token_counter">
64366513 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="mes_example_textarea">counting...</span>
64376514 </div>
@@ -7124,7 +7201,7 @@
71247201 <span>&nbsp;</span>
71257202 <span id="completion_prompt_manager_popup_entry_source"></span>
71267203 </div>
71277204 <textarea id="completion_prompt_manager_popup_entry_form_prompt" class="text_pole" name="prompt" data-macros data-macros-autocomplete="always" data-macros-autocomplete-style="expanded" placeholder="The prompt to be sent." data-i18n="[placeholder]The prompt to be sent."></textarea>
71287205 </div>
71297206 <div class="completion_prompt_manager_popup_entry_form_footer">
71307207 <a id="completion_prompt_manager_popup_entry_form_close" title="Close" data-i18n="[title]close" class="fa-solid fa-close menu_button"></a>
@@ -7410,7 +7487,7 @@
74107487 </div>
74117488 </div>
74127489 </summary>
74137490 <textarea data-macros name="alternate_greetings" data-i18n="[placeholder](This will be the first message from the character that starts every chat)" placeholder="(This will be the first message from the character that starts every chat)" class="text_pole textarea_compact alternate_greeting_text mdHotkeys" value="" autocomplete="off" rows="12"></textarea>
74147491 </details>
74157492 </div>
74167493 </div>
@@ -7500,7 +7577,7 @@
75007577 <b data-i18n="Unique to this chat">Unique to this chat</b>.<br>
75017578 <span data-i18n="Checkpoints inherit the Note from their parent, and can be changed individually after that.">Checkpoints inherit the Note from their parent, and can be changed individually after that.</span><br>
75027579 </small>
75037580 <textarea id="extension_floating_prompt" data-macros class="text_pole textarea_compact" rows="8"></textarea>
75047581 <div class="extension_token_counter">
75057582 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_prompt_token_counter">0</span>
75067583 </div>
@@ -7557,7 +7634,7 @@
75577634 <div class="inline-drawer-content">
75587635 <small data-i18n="Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.">Will be automatically added as the author's note for this character. Will be used in groups, but
75597636 can't be modified when a group chat is open.</small>
75607637 <textarea id="extension_floating_chara" data-macros class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>
75617638 <div class="extension_token_counter">
75627639 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_chara_token_counter">0</span>
75637640 </div>
@@ -7589,7 +7666,7 @@
75897666 </div>
75907667 <div class="inline-drawer-content">
75917668 <small data-i18n="Will be automatically added as the Author's Note for all new chats.">Will be automatically added as the Author's Note for all new chats.</small>
75927669 <textarea id="extension_floating_default" data-macros class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>
75937670 <div class="extension_token_counter">
75947671 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_default_token_counter">0</span>
75957672 </div>
public/locales/fr-fr.json+2 -0
@@ -290,6 +290,8 @@
290290 "View Remaining Credits": "Afficher les crédits restants",
291291 "OpenRouter Model": "Modèle OpenRouter",
292292 "Model Providers": "Fournisseurs de modèles",
293+ "Model Quantizations": "Quantifications du modèle",
294+ "Select quantizations. No selection = all quantizations.": "Sélectionnez les quantifications. Aucune sélection = toutes les quantifications.",
293295 "InfermaticAI API Key": "Clé API InfermaticAI",
294296 "InfermaticAI Model": "Modèle InfermaticAI",
295297 "DreamGen API key": "Clé API DreamGen",
public/locales/zh-cn.json+159 -177
@@ -185,9 +185,7 @@
185185 "Mirostat (mode=1 is only for llama.cpp)": "Mirostat(mode=1 仅用于 llama.cpp)",
186186 "Mirostat_desc": "Mirostat 是一个用于控制输出困惑度的恒温器",
187187 "Mirostat Mode": "Mirostat 模式",
188- "Variability parameter for Mirostat outputs": "Mirostat 输出的变异性参数。",
189188 "Mirostat Eta": "Mirostat η",
190- "Learning rate of Mirostat": "Mirostat 的学习率。",
191189 "Beam search": "束搜索",
192190 "A greedy, brute-force algorithm used in LLM sampling to find the most likely sequence of words or tokens. It expands multiple candidate sequences at once, maintaining a fixed number (beam width) of top sequences at each step.": "一种在LLM采样中使用的贪婪暴力算法,用于找到最可能的单词或标记序列。它一次扩展多个候选序列,在每一步保留固定数量(光束宽度)的最佳序列。",
193191 "# of Beams": "光束数量",
@@ -215,9 +213,9 @@
215213 "Spaces Between Special Tokens": "特殊词符之间的空格",
216214 "Seed_desc": "一个用于生成确定性和可复现的输出的随机种子。设置为 -1 时会使用随机种子。",
217215 "LLaMA / Mistral / Yi models only": "LLaMA / Mistral / Yi模型专用。首先确保您选择了适当的词符化器。\n这项设置决定了你不想在结果中看到的字符串。\n每行一个字符串。可以是文本或者[词符id]。\n许多词符以空格开头。如果不确定,请使用词符计数器。",
218216 "Global list": "Global list全局列表",
219217 "Example: some text [42, 69, 1337]": "例如:\n一些文本\n[42, 69, 1337]",
220218 "Preset-specific list": "Preset-specific list预设特有的列表",
221219 "CFG": "CFG",
222220 "Classifier Free Guidance. More helpful tip coming soon": "无分类器指导(CFG)。更多有用的提示敬请期待。",
223221 "Scale": "缩放比例",
@@ -228,6 +226,7 @@
228226 "GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF 或 EBNF,取决于使用的后端。如果您使用这个,您应该知道该用哪一个。",
229227 "JSON Schema": "JSON 结构",
230228 "Type in the desired JSON schema": "输入所需的 JSON 结构",
229+ "Allow empty schema objects": "允许空结构对象",
231230 "Top P & Min P": "Top P 和 Min P",
232231 "Load default order": "加载默认顺序",
233232 "Sampler Order": "取样器顺序",
@@ -250,14 +249,12 @@
250249 "Space": "空格",
251250 "Newline": "换行",
252251 "Double Newline": "双换行",
253- "Wrap user messages in quotes before sending": "在发送之前将用户消息用引号括起来",
254- "Wrap in Quotes": "用引号包裹",
255- "Wrap entire user message in quotes before sending.": "在发送之前用引号包裹整个用户消息。",
256- "Leave off if you use quotes manually for speech.": "如果您手动使用引号包裹对话,请忽略此项。",
257252 "Continue prefill": "继续预填充",
258253 "Continue sends the last message as assistant role instead of system message with instruction.": "继续发送的是作为助手角色的最后一条消息,而不是带有指示的系统消息。",
259254 "Squash system messages": "压缩系统消息",
260255 "Combines consecutive system messages into one (excluding example dialogues). May improve coherence for some models.": "将连续的系统消息合并为一条(不包括示例对话),可能会提高一些模型的连贯性。",
256+ "Use system prompt": "使用系统提示词",
257+ "Send the system prompt for supported models. If disabled, the user message is added to the beginning of the prompt.": "为支持的模型发送系统提示词。如果禁用,则用户消息将添加到提示词的开头。",
261258 "Enable web search": "启用联网搜索",
262259 "Use search capabilities provided by the backend.": "使用后端提供的联网搜索功能。",
263260 "openrouter_web_search_fee": "收费,每个提示词会多收 $0.02。",
@@ -268,20 +265,19 @@
268265 "enable_functions_desc_2": "功能工具",
269266 "enable_functions_desc_3": "可以被各种扩展利用来提供附加功能。",
270267 "enable_functions_desc_4": "当提示词后处理没有选择工具时不支持。",
271268 "Send inline imagesmedia": "发送图片发送内联媒体",
272269 "image_inlining_hint_1": "如果模型支持,就可以在提示词中发送媒体文件。",
270+ "video_inlining_hint_4": "视频必须在 20MB 以下且时长不超过1分钟。",
271+ "audio_inlining_hint_2": "音频必须小于 20 MB。",
273272 "Inline Image Quality": "图片画质",
274273 "openai_inline_image_quality_auto": "自动",
275274 "openai_inline_image_quality_low": "低",
276275 "openai_inline_image_quality_high": "高",
277- "Send inline videos": "发送视频",
278- "video_inlining_hint_4": "视频必须在 20MB 以下且时长不超过1分钟。",
279276 "Request inline images": "请求图片返回",
280277 "Allows the model to return image attachments.": "允许模型返回图片附件。",
281278 "Request inline images_desc_2": "与以下几个功能不兼容:函数调用、联网搜搜、系统提示词。",
282279 "Use system promptResolution": "使用系统提示词分辨率",
283280 "Merges_all_system_messages_desc_1Aspect Ratio": "合并所有系统消息,直到第一条具有非系统角色的消息,然后通过长宽比",
284- "Merges_all_system_messages_desc_2": "字段发送。",
285281 "Request model reasoning": "请求思维链",
286282 "Allows the model to return its thinking process.": "允许模型返回其思维过程。",
287283 "This setting affects visibility only.": "此设置只影响思维链是否可见。",
@@ -295,12 +291,18 @@
295291 "openai_reasoning_effort_maximum": "极高",
296292 "OpenAI-style options: low, medium, high. Minimum and maximum are aliased to low and high. Auto does not send an effort level.": "OpenAI式选项:低、中、高。极低等于低,极高等于高。选择自动,则不传入推理强度参数。",
297293 "Allocates a portion of the response length for thinking (min: 1024 tokens, low: 10%, medium: 25%, high: 50%, max: 95%), but minimum 1024 tokens. Auto does not request thinking.": "将最大回复空间的一部分分配给思维链(极低:1024词符,低:10%,中:25%,高:50%,极高:95%),最低1024词符。选择“自动”不会请求模型思维链。",
294+ "Sets a dynamic reasoning depth level for thinking (Flash 3/Pro 3). High and low are supported by both, minimal and medium are Flash 3 only. Auto lets the model decide.": "设置动态推理强度等级(3 Flash / 3 Pro)。两者都支持高和低,但极低和中只有 3 Flash 支持。自动会让模型自己选择。",
298295 "Allocates a portion of the response length for thinking (Flash 2.5/Pro 2.5) (min: 0/128 tokens, low: 10%, medium: 25%, high: 50%, max: 24576/32768 tokens). Auto lets the model decide.": "将最大回复空间的一部分分配给思维链(仅 2.5 Flash / 2.5 Pro 模型)(极低:0/128词符,低:10%,中:25%,高:50%,极高:24576/32768词符),最低1024词符。选择“自动”会让模型自己决定。",
296+ "Verbosity": "长度",
297+ "openai_verbosity_auto": "自动",
298+ "openai_verbosity_low": "低",
299+ "openai_verbosity_medium": "中",
300+ "openai_verbosity_high": "高",
301+ "Constrains the verbosity of the model's response.": "限制模型回复的长度。",
299302 "Assistant Prefill": "AI预填",
300303 "Expand the editor": "展开编辑器",
301304 "Start Claude's answer with...": "以如下内容开始Claude的回答...",
302305 "Assistant Impersonation Prefill": "AI帮答预填",
303- "Send the system prompt for supported models. If disabled, the user message is added to the beginning of the prompt.": "为支持的模型发送系统提示词。如果禁用,则用户消息将添加到提示词的开头。",
304306 "Confirm token parsing with": "确认使用以下工具进行词符解析",
305307 "Tokenizer": "分词器",
306308 "New preset": "新预设",
@@ -381,7 +383,7 @@
381383 "Date Desc": "日期倒序",
382384 "category": "分类",
383385 "Top": "热门",
384386 "New": "新建最新",
385387 "All": "全部",
386388 "All Classes": "所有分类",
387389 "Toggle grid view": "切换网格视图",
@@ -398,6 +400,7 @@
398400 "Aphrodite Model": "Aphrodite 模型",
399401 "ggml-org/llama.cpp": "ggml-org/llama.cpp",
400402 "Example: http://127.0.0.1:8080": "示例:http://127.0.0.1:8080",
403+ "llama.cpp Model": "llama.cpp 模型",
401404 "Example: http://127.0.0.1:11434": "示例:http://127.0.0.1:11434",
402405 "Ollama Model": "Ollama 模型",
403406 "Download": "下载",
@@ -465,7 +468,7 @@
465468 "(Express mode)": "(快速模式)",
466469 "API Key": "API 密钥",
467470 "Project ID": "项目ID:",
468471 "Project ID is required when selecting regions other than the default (us-central1). You can find this in a model 404 error message.": "仅当选择非默认区域(us-central1)时才需要` 项目ID`。\n 您可以在模型 404 错误消息中找到它。",
469472 "Service Account Configuration": "服务帐户配置",
470473 "Service Account JSON Content": "服务帐户 JSON 内容:",
471474 "For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.": "出于隐私考虑,你的服务账号 JSON 内容将在点击“验证JSON”后隐藏。",
@@ -478,6 +481,13 @@
478481 "Groq Model": "Groq 模型",
479482 "Electron Hub API Key": "Electron Hub API 密钥",
480483 "Electron Hub Model": "Electron Hub 模型",
484+ "Electron Hub Model Sorting": "Electron Hub 模型排序",
485+ "Input Price": "输入价格(最便宜)",
486+ "Output Price": "输出价格(最便宜)",
487+ "Chutes API Key": "Chutes API 密钥",
488+ "View Billing/Balance": "查看账单/余额",
489+ "Chutes Model": "Chutes 模型",
490+ "Chutes Model Sorting": "Chutes 模型排序",
481491 "NanoGPT API Key": "NanoGPT API 密钥",
482492 "NanoGPT Model": "NanoGPT 模型",
483493 "DeepSeek API Key": "DeepSeek API 密钥",
@@ -506,6 +516,19 @@
506516 "Avoid sending sensitive information. Provider's outputs may include ads.": "请避免发送敏感信息。输出可能有提供商的广告。",
507517 "Moonshot AI API Key": "Moonshot AI API 密钥",
508518 "Moonshot AI Model": "Moonshot AI 模型",
519+ "Z.AI API Key": "Z.AI API 密钥",
520+ "Z.AI Endpoint": "Z.AI 端点",
521+ "Common API": "通用 API",
522+ "Coding API": "编码 API",
523+ "Z.AI Model": "Z.AI 模型",
524+ "Azure Base URL": "Azure 基础 URL",
525+ "Deployment Name": "部署名称",
526+ "The name of your model deployment in Azure.": "你在 Azure 中的模型部署名称。",
527+ "API Version": "API 版本",
528+ "Azure API Key": "Azure API 密钥",
529+ "Model Name": "模型名称",
530+ "Click 'Connect' to fetch model name": "点击“连接”以获取模型名称",
531+ "The underlying model of your deployment. This is detected automatically when you connect.": "你部署的底层模型。连接时会自动检测。",
509532 "Prompt Post-Processing": "提示词后处理",
510533 "Applies additional processing to the prompt before sending it to the API.": "在将提示词发送到 API 之前对其进行额外处理。",
511534 "prompt_post_processing_none": "未选择",
@@ -529,6 +552,7 @@
529552 "Master Import": "全局导入",
530553 "Export Advanced Formatting settings": "导出高级格式化设置",
531554 "Master Export": "全局导出",
555+ "Grayed-out options have no effect when Chat Completion API is used.": "灰色选项在使用 聊天补全API 时无效。",
532556 "Context Template": "上下文模板",
533557 "context_derived": "若可能,从模型的元数据获取。",
534558 "Select your current Context Template": "选择你当前的上下文模板",
@@ -728,6 +752,7 @@
728752 "Delete a theme": "删除主题",
729753 "Update a theme file": "更新主题文件",
730754 "Save as a new theme": "另存为新主题",
755+ "This style applies to all avatars globaly, including your Persona, Character Management, Account selection, etc.": "此样式将应用在所有头像,包括您的用户设定、角色管理、帐户选择等。",
731756 "Avatar Style:": "头像样式:",
732757 "Circle": "圆形",
733758 "Square": "正方形",
@@ -737,6 +762,10 @@
737762 "Flat": "扁平",
738763 "Bubbles": "气泡",
739764 "Document": "文档",
765+ "Default display style for media attachments in chat messages. Extensions can override this setting.": "聊天消息中媒体附件的默认显示样式。扩展可以覆盖此设置。",
766+ "Media Style:": "媒体样式:",
767+ "List": "列表",
768+ "Gallery": "画廊",
740769 "Notifications:": "通知:",
741770 "Top Left": "左上",
742771 "Top Center": "顶部居中",
@@ -835,9 +864,13 @@
835864 "Find and delete backups, unused chats, files, images, etc.": "寻找和删除备份、未使用的聊天、文件、图片等。",
836865 "Clean-Up": "清理",
837866 "Smooth Streaming": "平滑流式传输",
838867 "Experimental feature. MayBypass notsmooth workstreaming forin allreasoning backendsblocks.": "实验性功能。可能不适用于所有后端在推理块中不使用平滑流式传输。",
868+ "Exclude 'Thinking...'": "排除“思考中...”",
839869 "Slow": "慢",
840870 "Fast": "快",
871+ "Fade in streamed text when it appears, instead of it just popping in": "流式传输的文本淡入显示,而不是直接弹出",
872+ "Stream Fade-In": "流式淡入",
873+ "Experimental feature. May not work for all backends.": "实验性功能。可能不适用于所有后端。",
841874 "Play a sound when a message generation finishes": "当消息生成完毕时播放声音",
842875 "Message Sound": "消息声音",
843876 "Only play a sound when ST's browser tab is unfocused": "仅在ST的浏览器标签页未被打开时播放声音",
@@ -871,6 +904,9 @@
871904 "Gradual push-out": "逐渐推出",
872905 "Always include examples": "始终包含示例",
873906 "Never include examples": "永不包含示例",
907+ "Image Swipe Behavior:": "图片滑动刷新行为:",
908+ "Generate new": "生成新的",
909+ "Roll over": "循环现有",
874910 "Send on Enter": "按 Enter 发送",
875911 "Disabled": "已禁用",
876912 "Automatic (PC)": "自动(PC)",
@@ -896,6 +932,8 @@
896932 "Allow {{user}}: in bot messages": "在机器人消息中允许 {{user}}: ",
897933 "Skip encoding and characters in message text, allowing a subset of HTML markup as well as Markdown": "跳过消息文本中的编码和字符,允许一部分HTML标记以及Markdown",
898934 "Show tags in responses": "在响应中显示标签",
935+ "Experimental Macro Engine": "实验性宏引擎",
936+ "Experimental feature. Currently in development to test.": "实验性功能。目前正在开发测试中。",
899937 "Allow AI messages in groups to contain lines spoken by other group members": "允许群聊中的AI输出群中其他成员说的话",
900938 "Relax message trim in Groups": "减轻群聊中的消息修剪",
901939 "Log prompts to console": "将提示词输出到控制台",
@@ -960,10 +998,15 @@
960998 "Center": "居中",
961999 "Automatically select a background based on the chat context": "根据聊天上下文自动选择背景",
9621000 "Auto-select": "自动选择",
1001+ "Add a new background": "添加新背景",
9631002 "Add Background": "添加背景",
9641003 "Global": "全局",
1004+ "Chat": "聊天",
1005+ "Make thumbnails smaller": "缩小缩略图",
1006+ "Make thumbnails larger": "放大缩略图",
9651007 "bg_chat_hint_1": "使用生成的聊天背景",
9661008 "bg_chat_hint_2": "扩展名将出现在这里。",
1009+ "Scroll backgrounds to top": "回顶",
9671010 "Extensions": "扩展",
9681011 "Notify on extension updates": "在扩展更新时通知",
9691012 "Manage extensions": "管理扩展",
@@ -1004,7 +1047,6 @@
10041047 "Click to lock your selected persona to the current character. Click again to remove the lock.": "点击将选择的用户设定与当前角色绑定。再次点击以解绑。",
10051048 "Character": "角色",
10061049 "Click to lock your selected persona to the current chat. Click again to remove the lock.": "点击将选择的人设与当前聊天绑定。再次点击以解绑。",
1007- "Chat": "聊天",
10081050 "Global Settings": "全局设置",
10091051 "Show notifications on switching personas": "切换用户设定时显示通知",
10101052 "When multiple personas are connected to a character, a popup will appear to select which one to use": "当多个用户设定与一个角色绑定时,会弹出一个弹窗让用户选择使用哪一个。",
@@ -1038,7 +1080,7 @@
10381080 "More...": "更多...",
10391081 "Link to World Info": "链接到世界书",
10401082 "Import Card Lore": "导入角色卡的世界书",
10411083 "ScenarioCharacter OverrideSettings Overrides": "场景覆盖角色设置覆盖",
10421084 "Convert to Persona": "转换为用户角色",
10431085 "Rename": "重命名",
10441086 "Link to Source": "来源链接",
@@ -1078,7 +1120,7 @@
10781120 "When 'Join character cards' is selected, all respective fields of the characters are being joined together.This means that in the story string for example all character descriptions will be joined to one big text.If you want those fields to be separated, you can define a prefix or suffix here.This value supports normal macros and will also replace {{char}} with the relevant char's name and <FIELDNAME> with the name of the part (e.g.: description, personality, scenario, etc.)": "当选择“合并角色卡”时,角色的所有相应字段将被合并在一起。这意味着在故事字符串中,例如,所有角色描述都将合并为一个大文本。如果您希望将这些字段分开,可以在此处定义前缀或后缀。此值支持普通宏,还会将 {{char}} 替换为相关角色的名称,将 <FIELDNAME> 替换为部分的名称(例如:描述、个性、场景等)",
10791121 "Inserted after each part of the joined fields.": "插入到加入字段的每个部分之后。",
10801122 "Join Suffix": "加入后缀",
10811123 "Set a group chat scenariocharacter settings overrides": "设置群聊背景设置群聊角色设置覆盖",
10821124 "Click to allow/forbid the use of external media for this group.": "单击以允许/禁止该组使用外部媒体。",
10831125 "Restore collage avatar": "恢复拼贴头像",
10841126 "Allow self responses": "允许自我回复",
@@ -1156,9 +1198,9 @@
11561198 "Save": "保存",
11571199 "Chat History": "聊天记录",
11581200 "Import Chat": "导入聊天",
1159- "Copy to global backgrounds": "复制到全局背景",
11601201 "Lock": "锁定",
11611202 "Unlock": "解锁",
1203+ "Copy to global backgrounds": "复制到全局背景",
11621204 "Rename Background": "重命名背景",
11631205 "Delete Background": "删除背景",
11641206 "Select a World Info file for": "选择一个世界书文件给",
@@ -1191,6 +1233,8 @@
11911233 "Optional Filter": "可选过滤器",
11921234 "Keywords or Regexes (ignored if empty)": "关键字或正则表达式(如果为空则忽略)",
11931235 "Comma separated list (ignored if empty)": "逗号分隔列表(如果为空则忽略)",
1236+ "wi_outlet_name": "为此世界信息条目设置锚点名称。\n\n位置为“锚点”的世界信息条目不会自动添加到提示词中。相反,它们将被收集并可作为提示词中的宏使用。\n在提示词中任何想要添加此特定锚点的所有世界信息条目的位置添加 {{outlet::YourName}}。",
1237+ "Outlet Name": "锚点名称",
11941238 "Use global setting": "使用全局设置",
11951239 "Case-Sensitive": "区分大小写",
11961240 "Use global": "使用全局",
@@ -1260,6 +1304,7 @@
12601304 "at Depth System": "@D ⚙ [系统]在深度​​️",
12611305 "at Depth User": "@D 👤 [用户]在深度",
12621306 "at Depth AI": "@D 🤖 [AI]在深度",
1307+ "Outlet": "➡️ 锚点",
12631308 "Depth": "深度",
12641309 "Order:": "顺序:",
12651310 "Order": "顺序",
@@ -1302,6 +1347,7 @@
13021347 "Narrate": "朗读",
13031348 "Exclude message from prompts": "从提示词中排除消息",
13041349 "Include message in prompts": "将消息包含在提示词中",
1350+ "Toggle media display style": "切换媒体显示样式",
13051351 "Embed file or image": "嵌入文件或图像",
13061352 "Create checkpoint": "创建检查点",
13071353 "Create Branch": "创建分支",
@@ -1321,10 +1367,6 @@
13211367 "Collapse all reasoning blocks": "折叠所有推理块",
13221368 "Copy reasoning": "复制推理内容",
13231369 "Edit reasoning": "编辑推理内容",
1324- "Expand and zoom": "展开并缩放",
1325- "Caption": "标题",
1326- "Swipe left": "向左滑动",
1327- "Swipe right": "向右滑动",
13281370 "Welcome to SillyTavern!": "欢迎来到 SillyTavern!",
13291371 "SillyTavern is aimed at advanced users.": "SillyTavern 面向高级用户。",
13301372 "welcome_message_part_1": "阅读",
@@ -1362,6 +1404,12 @@
13621404 "(This will be the first message from the character that starts every chat)": "(这是每次聊天开始时角色的第一条消息)",
13631405 "View contents": "查看内容",
13641406 "Remove the file": "删除文件",
1407+ "Expand and zoom": "展开并缩放",
1408+ "Caption": "标题",
1409+ "Swipe left": "向左滑动",
1410+ "Swipe right": "向右滑动",
1411+ "Play": "播放",
1412+ "Mute": "静音",
13651413 "Author's Note": "作者注释",
13661414 "Unique to this chat": "仅对此聊天生效",
13671415 "Checkpoints inherit the Note from their parent, and can be changed individually after that.": "检查点从其父级继承注释,之后可以单独更改。",
@@ -1479,6 +1527,7 @@
14791527 "API": "API",
14801528 "Text Generation WebUI (oobabooga)": "文本生成 WebUI (oobabooga)",
14811529 "Model": "模型",
1530+ "Refresh model list": "刷新模型列表",
14821531 "currently_selected": "[当前选定]",
14831532 "currently_loaded": "[当前正在加载]",
14841533 "Custom Model Tag": "自定义模型标签",
@@ -1513,21 +1562,21 @@
15131562 "Character Expressions": "角色表情",
15141563 "Use the selected API from Chat Translation extension settings.": "使用聊天翻译扩展程序中已选择的API。",
15151564 "Translate text to English before classification": "分类之前将文本翻译成英文",
15161565 "A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.": "A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.使单个关键词可以有多个表情包。每当出现该关键词时,将随机选择其中一个。",
15171566 "Allow multiple sprites per expression": "Allow multiple sprites per expression允许关键词重复",
15181567 "If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.": "If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.再次使用相同关键词时,将重新刷新表情包。仅适用于关键词重复的表情包。",
15191568 "Re-roll if same expression is used again": "Re-roll if same sprite is used again再次使用相同关键词时刷新表情包。",
15201569 "Classifier API": "分类器 API",
15211570 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
15221571 "Main API": "当前连接的 API",
15231572 "WebLLM Extension": "WebLLM 扩展程序",
15241573 "When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.": "When using使用 LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.分类器时,仅显示和使用已分配表情包的关键词。",
15251574 "Filter expressions for available sprites": "Filter expressions for available sprites筛选已有表情包的关键词",
15261575 "LLM Prompt": "大语言模型提示词",
15271576 "Used in addition to JSON schemas and function calling.": "Used in addition to JSON可与 schemasJSON结构 and function函数调用 calling.一同使用。",
15281577 "LLM Prompt Strategy": "LLM Prompt Strategy提示词策略",
15291578 "Limited Context": "Limited Context限制上下文",
15301579 "Full Context": "Full Context完整上下文",
15311580 "Default / Fallback Expression": "默认/后备表达式",
15321581 "Set the default and fallback expression being used when no matching expression is found.": "设置在未找到匹配表达式时使用的默认表达式和后备表达式。",
15331582 "Custom Expressions": "自定义表达式",
@@ -1640,46 +1689,53 @@
16401689 "macro for manual injection)": "宏用于手动注入)",
16411690 "Color": "颜色",
16421691 "Only apply color as accent": "仅应用颜色作为强调",
1643- "ext_regex_new_global_script_desc": "新增「全局」正则表达式",
1644- "ext_regex_new_scoped_script_desc": "新增「局部」正则表达式",
1645- "ext_regex_new_preset_script_desc": "新增「预设」正则表达式",
16461692 "ext_regex_debugger_active_rules": "激活的规则",
1693+ "ext_regex_debugger_save_order_help": "保存当前规则顺序",
16471694 "ext_regex_debugger_save_order": "保存此顺序",
16481695 "ext_regex_debugger_testing_area": "测试区域",
16491696 "ext_regex_debugger_raw_input": "原始输入",
1697+ "ext_regex_debugger_run_test_help": "运行测试流程",
16501698 "ext_regex_debugger_run_test": "运行测试",
16511699 "ext_regex_debugger_display_replace": "替换",
16521700 "ext_regex_debugger_display_highlight": "高亮",
16531701 "ext_regex_debugger_render_text": "渲染为文本",
16541702 "ext_regex_debugger_render_message": "渲染为消息",
16551703 "ext_regex_debugger_step_by_step": "逐步转换",
1704+ "Expand view": "展开视图",
16561705 "ext_regex_debugger_final_output": "最终输出",
1706+ "Edit Rule": "编辑规则",
16571707 "ext_regex_title": "正则",
16581708 "ext_regex_presetsext_regex_new_global_script_desc": "正则预设新增「全局」正则表达式",
1659- "ext_regex_presets_desc": "可以轻松保存并切换多组正则开关状态。",
1660- "ext_regex_preset_create": "创建新预设",
1661- "ext_regex_preset_update": "更新已有预设",
1662- "ext_regex_preset_apply": "重新应用当前预设",
1663- "ext_regex_preset_delete": "删除当前预设",
16641709 "ext_regex_new_global_script": "新建全局正则",
16651710 "ext_regex_new_scoped_scriptext_regex_new_preset_script_desc": "新建局部正则新增「预设」正则表达式",
16661711 "ext_regex_new_preset_script": "新建预设正则",
1712+ "ext_regex_new_scoped_script_desc": "新增「局部」正则表达式",
1713+ "ext_regex_new_scoped_script": "新建局部正则",
16671714 "ext_regex_import_script": "导入正则",
16681715 "ext_regex_bulk_edit": "批量编辑",
16691716 "ext_regex_debugger_desc": "高级正则调试工具",
16701717 "ext_regex_debugger": "调试工具",
1718+ "ext_regex_move_to_global": "移至全局",
1719+ "ext_regex_move_to_preset": "移至预设",
1720+ "ext_regex_move_to_scoped": "移至局部",
16711721 "Export": "导出",
1722+ "ext_regex_presets": "正则预设",
1723+ "ext_regex_presets_desc": "可以轻松保存并切换多组正则开关状态。",
1724+ "ext_regex_preset_create": "创建新预设",
1725+ "ext_regex_preset_update": "更新已有预设",
1726+ "ext_regex_preset_apply": "重新应用当前预设",
1727+ "ext_regex_preset_delete": "删除当前预设",
16721728 "ext_regex_global_scripts": "全局正则脚本",
16731729 "ext_regex_global_scripts_desc": "影响所有角色,保存在本地设定中",
16741730 "No scripts found": "没有找到脚本",
1675- "ext_regex_scoped_scripts": "局部正则脚本",
1676- "ext_regex_scoped_scripts_desc": "只影响当前角色,保存在角色卡片中",
16771731 "ext_regex_preset_scripts": "预设正则脚本",
1732+ "ext_regex_disallow_preset": "不允许使用预设正则",
1733+ "ext_regex_allow_preset": "允许使用预设正则",
16781734 "ext_regex_preset_scripts_desc": "只影响当前预设,保存在预设中",
1735+ "ext_regex_scoped_scripts": "局部正则脚本",
16791736 "ext_regex_disallow_scoped": "不允许使用局部正则",
16801737 "ext_regex_allow_scoped": "允许使用局部正则",
16811738 "ext_regex_disallow_presetext_regex_scoped_scripts_desc": "不允许使用预设正则只影响当前角色,保存在角色卡片中",
1682- "ext_regex_allow_preset": "允许使用预设正则",
16831739 "Regex Editor": "正则表达式编辑器",
16841740 "Test Mode": "测试模式",
16851741 "ext_regex_desc": "“正则”是一个使用“正则表达式”来查找/替换字符串的工具。如果您想了解更多信息,请点击标题旁边的“?”。",
@@ -1725,22 +1781,17 @@
17251781 "Would you like to allow using them?": "你想要启用它们吗?",
17261782 "If you want to do it later, select 'Regex' from the extensions menu.": "你可以稍后在扩展栏的 \"正则\" 区域管理它们。",
17271783 "ext_regex_import_target": "导入至:",
1784+ "This preset has embedded regex script(s).": "此预设包含内置正则脚本。",
17281785 "ext_regex_disable_script": "禁用脚本",
17291786 "ext_regex_enable_script": "启用脚本",
17301787 "ext_regex_edit_scriptShow more options": "编辑脚本展示更多选项",
1731- "ext_regex_move_to_global": "移至全局",
1732- "ext_regex_move_to_scoped": "移至局部",
1733- "ext_regex_move_to_preset": "移至预设",
17341788 "ext_regex_export_script": "导出脚本",
1789+ "ext_regex_edit_script": "编辑脚本",
17351790 "ext_regex_delete_script": "删除脚本",
1736- "This preset has embedded regex script(s).": "此预设包含内置正则脚本。",
1737- "Preset '${0}' contains enabled regex scripts": "预设 '${0}' 包含被启用的正则脚本",
1738- "Reload the chat for regex to take effect": "重新加载聊天以使正则生效",
1739- "Click here to reload immediately": "点击此处立即重新加载",
1740- "If you want to do it later, select \"Regex\" from the extensions menu.": "您可稍后从扩展菜单中的(Regex)启用。",
17411791 "Trigger Stable Diffusion": "触发Stable Diffusion",
17421792 "Abort current image generation task": "中止当前图像生成",
17431793 "Stop Image Generation": "停止图像生成",
1794+ "Send me a picture of:": "给我发一张……的照片:",
17441795 "sd_Yourself": "你自己",
17451796 "sd_Your_Face": "你的脸",
17461797 "sd_Me": "我",
@@ -1751,8 +1802,8 @@
17511802 "Image Generation": "图像生成",
17521803 "sd_refine_mode": "允许在将提示词发送到生成 API 之前手动编辑提示词",
17531804 "sd_refine_mode_txt": "生成之前编辑提示词",
1754- "sd_function_tool": "Use the function tool to automatically detect intents to generate images.",
1805+ "sd_function_tool": "使用函数工具自动检测生成图像的意图。",
17551806 "sd_function_tool_txt": "Use function tool使用函数工具",
17561807 "sd_interactive_mode": "发送消息时自动生成图像,例如“给我发一张猫的照片”。",
17571808 "sd_interactive_mode_txt": "交互模式",
17581809 "sd_multimodal_captioning": "使用多模态字幕根据用户和角色的头像生成提示词。",
@@ -1770,35 +1821,45 @@
17701821 "sd_auto_auth_warning_2": "注意!服务器必须可从 SillyTavern 主机访问。",
17711822 "sd_drawthings_url": "例如:{{drawthings_url}}",
17721823 "sd_drawthings_auth_txt": "运行 DrawThings 应用程序并在 UI 中启用 HTTP API 开关!必须可以从 SillyTavern 主机访问服务器。",
1773- "Model ID": "Model ID",
1824+ "Hint: Save an API key in the Hugging Face (Text Completion) API settings to use it here.": "提示:在 Hugging Face(文本补全)API 设置中保存一个 API 密钥以在此处使用。",
1825+ "Model ID": "模型 ID",
17741826 "e.g. black-forest-labs/FLUX.1-dev": "例如:black-forest-labs/FLUX.1-dev",
1827+ "Hint: Save an API key in the Chutes (Chat Completion) API settings to use it here.": "提示:在 Chutes(聊天补全)API 设置中保存一个 API 密钥以在此处使用。",
1828+ "Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.": "提示:在 Electron Hub(聊天补全)API 设置中保存一个 API 密钥以在此处使用。",
1829+ "Image Quality": "画面质量",
1830+ "Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.": "提示:在 NanoGPT(聊天补全)API 设置中保存一个 API 密钥以在此处使用。",
17751831 "sd_vlad_url": "例如:{{vlad_url}}",
17761832 "The server must be accessible from the SillyTavern host machine.": "必须能够从 SillyTavern 主机访问该服务器。",
17771833 "Hint: Save an API key in AI Horde API settings to use it here.": "提示:在 Horde AI API 设置中保存一个 API 密钥以便在此处使用它密钥以在此处使用。",
17781834 "Allow NSFW images from Horde": "允许来自 Horde 的 NSFW 图片",
17791835 "Sanitize prompts (recommended)": "净化提示词(推荐)",
17801836 "Automatically adjust generation parameters to ensure free image generations.": "自动调整生成参数,确保图像生成自由。",
17811837 "Avoid spending Anlas": "避免花费 Anlas",
17821838 "Opus tier": "(作品层Opus 级别)",
17831839 "View my Anlas": "查看我的目录查看我的 Anlas",
1840+ "Hint: Save an API key in the NovelAI API settings to use it here.": "提示:在 NovelAI API 设置中保存一个 API 密钥以在此处使用。",
17841841 "Click to set": "点击设置",
1785- "These settings only apply to DALL-E 3": "这些设置仅适用于 DALL-E 3",
17861842 "Image Style": "图像风格",
1787- "Image Quality": "画面质量",
17881843 "Standard": "标准",
17891844 "HD": "高清",
1845+ "Duration": "持续时间",
1846+ "Short (4 seconds)": "短(4秒)",
1847+ "Medium (8 seconds)": "中(8秒)",
1848+ "Long (16 seconds)": "长(16秒)",
17901849 "sd_comfy_url": "例如:{{comfy_url}}",
1850+ "sd_comfy_runpod_url": "eg: https://api.runpod.ai/v2/<your endpoint id>",
17911851 "Open workflow editor": "打开工作流编辑器",
17921852 "Create new workflow": "创建新的工作流",
17931853 "Delete workflow": "删除工作流",
1854+ "Enables prompt enhancing (passes prompts through an LLM to add detail).": "允许提示词增强(通过大语言模型处理提示词以添加细节)。",
17941855 "Enhance": "提高",
17951856 "You can find your API key in the Stability AI dashboard.": "您可以在 Stability AI 仪表板中找到您的 API 密钥。",
17961857 "Style Preset": "风格预设",
17971858 "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.": "是否对提示词使用提示词增强(Upsampling)。若开启,则会自动修改提示词,使回复更有创造力。",
17981859 "Prompt Upsampling": "提示词增强(Upsampling)",
1860+ "Duration (Veo)": "持续时间(Veo)",
17991861 "Sampling method": "采样方法",
18001862 "Scheduler": "调度器",
1801- "Resolution": "分辨率",
18021863 "Upscaler": "图像扩大器",
18031864 "Sampling steps": "采样步数",
18041865 "Width": "宽度",
@@ -1812,15 +1873,15 @@
18121873 "Hires. Fix": "高清修复",
18131874 "Karras": "Karras",
18141875 "Not all samplers supported.": "并非所有采样器都受支持。",
1815- "sd_adetailer_face": "Use ADetailer with face model during the generation. The ADetailer extension must be installed on the backend.",
1876+ "sd_adetailer_face": "使用 ADetailer 脸部模型进行生成。后端必须安装 ADetailer 扩展。",
18161877 "Use ADetailer (Face)": "使用 ADetailer(脸部)",
18171878 "SMEA versions of samplers are modified to perform better at high resolution.": "SMEA 版本的采样器经过修改,在高分辨率下性能更佳。",
18181879 "SMEA": "中小企业协会",
18191880 "DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.": "SMEA 采样器的 DYN 变体通常会产生更加多样化的输出,但在非常高的分辨率下可能会失败。",
18201881 "DYN": "动态",
18211882 "Decrisper": "去伪器",
18221883 "Enable guidance only after body has been formed, to improve diversity and saturation of samples. May reduce relevance": "Enable guidance only after body has been formed, to improve diversity and saturation of samples. May reduce relevance仅在图像主体形成后启用引导,以提高样本的多样性和饱和度。可能会降低相关性",
18231884 "Variety+": "Variety多样性+",
18241885 "(-1 for random)": "(“-1”为随机)",
18251886 "Preset for prompt prefix and negative prompt": "提示词前缀和负面提示词的预设",
18261887 "Style": "风格",
@@ -1863,6 +1924,7 @@
18631924 "ext_translate_target_lang": "目标语言",
18641925 "ext_translate_clear": "清空设置",
18651926 "Select TTS Provider": "选择 文本转语音 的服务提供商",
1927+ "tts_refresh": "刷新",
18661928 "tts_enabled": "已启用",
18671929 "Narrate user messages": "朗读用户消息",
18681930 "Auto Generation": "自动生成",
@@ -1875,18 +1937,22 @@
18751937 "Skip codeblocks": "跳过代码块",
18761938 "Skip tagged blocks": "跳过标签块里的内容(<标签>跳过这里</标签>)",
18771939 "Pass Asterisks to TTS Engine": "将星号传递给文本转语音服务",
18781940 "Works best when: Pass Asterisks to TTS Engine is enabled, and both Only narrate quotes and Ignore *text, even 'quotes', inside asterisks* are disabled.": "Works best when: Pass Asterisks to TTS Engine is enabled, and both Only narrate quotes and Ignore 最佳效果:启用“将星号传递给文本转语音服务”,同时禁用“只朗读引号内文本”和“忽略*text, even 'quotes', inside asterisks星号内文本* are disabled.(即使其被引号包裹)”功能。",
18791941 "Different voices for quotes and text inside asterisks": "Different voices for \"quotes\", 为“引号内文本”、*text inside asterisks星号内文本* and other text使用不同的声音",
18801942 "Audio Playback Speed": "音频播放速度",
1943+ "Available voices": "可用声音",
18811944 "Vector Storage": "向量存储",
18821945 "Vectorization Source": "向量化源",
18831946 "Local (Transformers)": "本地(Transformers)",
1884- "Secondary Embedding endpoint URL": "Secondary Embedding endpoint URL",
18851947 "Vectorization Model": "向量化模型",
1948+ "Hint: Set your Chutes API key in API Connections.": "提示:在 API 连接设置中设置 Chutes API 密钥。",
1949+ "Hint: Set your Electron Hub API key in API Connections.": "提示:在 API 连接设置中设置 Electron Hub API 密钥。",
1950+ "Secondary Embedding endpoint URL": "次级向量化端点 URL",
18861951 "Keep model in memory": "将模型保存在内存中",
18871952 "Hint: Set the URL in the API connection settings.": "提示:在 API 连接设置中设置 URL。",
18881953 "The server MUST be started with the --embedding flag to use this feature!": "服务器必须使用 --embedding 标志启动才能使用此功能!",
18891954 "NomicAI API Key": "NomicAI API 密钥",
1955+ "Hint: Set your OpenRouter API key in API Connections.": "提示:在 API 连接设置中设置 OpenRouter API 密钥。",
18901956 "Query messages": "查询消息",
18911957 "Score threshold": "分数阈值",
18921958 "Chunk boundary": "区块边界",
@@ -2115,106 +2181,16 @@
21152181 "World Info:": "世界书:",
21162182 "Chat History:": "聊天记录:",
21172183 "Extensions:": "扩展程序:",
21182184 "Bias:": "Bias:偏置:",
21192185 "Total Tokens in Prompt:": "提示词的总Token数量:",
21202186 "Max Context": "最大上下文:",
21212187 "(Context Size - Response Length)": "(上下文长度 - 回复长度)",
2122- "System-wide Replacement Macros (in order of evaluation):": "系统范围的替换宏(按评估顺序):",
2123- "help_macros_1": "仅适用于斜线命令批处理。替换为上一个命令的返回结果。",
2124- "help_macros_2": "仅插入一个换行符。",
2125- "help_macros_3": "修剪此宏周围的换行符。",
2126- "help_macros_4": "没有操作,只是一个空字符串。",
2127- "help_macros_5": "API 设置中定义的全局提示。仅在高级定义提示覆盖中有效。",
2128- "help_macros_6": "用户输入",
2129- "help_macros_7": "角色的主提示覆盖",
2130- "help_macros_8": "角色越狱提示覆盖",
2131- "help_macros_9": "角色描述",
2132- "help_macros_10": "人物性格",
2133- "help_macros_11": "角色场景",
2134- "help_macros_12": "您当前的角色描述",
2135- "help_macros_13": "角色对话示例",
2136- "help_macros_14": "未格式化的对话示例",
2137- "help_macros_summary": "“总结”扩展程序生成的最新聊天总结(如果有)。",
2138- "help_macros_15": "您当前的用户设定名称",
2139- "help_macros_16": "角色的名字",
2140- "help_macros_17": "角色的版本号",
2141- "help_macros_charDepthPrompt": "角色的 @ 深度注释",
2142- "help_macros_18": "以逗号分隔的群成员名称列表或单人聊天中的角色名称。别名:{{charIfNotGroup}}",
2143- "help_groupNotMuted": "与 {{group}} 相同,但排除被禁言的成员",
2144- "help_macros_19": "当前选定的 API 的文本生成模型名称。",
2145- "Can be inaccurate!": "不一定准确!",
2146- "help_macros_20": "最新聊天消息的文本。",
2147- "help_macros_lastUser": "最后的用户聊天消息文本。",
2148- "help_macros_lastChar": "最后的角色聊天消息文本。",
2149- "help_macros_21": "最新聊天消息的索引号。对于斜线命令批处理很有用。",
2150- "help_macros_22": "上下文中包含的第一条消息的 ID。要求在当前会话中至少运行一次生成。",
2151- "help_macros_firstDisplayedMessageId": "第一条载入可见聊天的消息的ID",
2152- "help_macros_23": "最后一条聊天消息中当前滑动的 ID(以 1 为基数)。如果最后一条消息是用户或提示隐藏的,则为空字符串。",
2153- "help_macros_24": "最后一条聊天消息中的滑动次数。如果最后一条消息是用户隐藏或提示隐藏的,则为空字符串。",
2154- "help_macros_reverse": "反转宏的内容。",
2155- "help_macros_25": "您可以在此处留言,宏将被替换为空白内容。AI 看不到。",
2156- "help_macros_26": "当前时间",
2157- "help_macros_27": "当前日期",
2158- "help_macros_28": "当前工作日",
2159- "help_macros_29": "当前 ISO 时间(24 小时制)",
2160- "help_macros_30": "当前 ISO 日期 (YYYY-MM-DD)",
2161- "help_macros_31": "指定格式的当前日期/时间,例如德国日期/时间:",
2162- "help_macros_32": "指定 UTC 时区偏移量的当前时间,例如 UTC-4 或 UTC+2",
2163- "help_macros_33": "time1 和 time2 之间的时间差。接受时间和日期宏。(例如:{{timeDiff::{{isodate}} {{time}}::2024/5/11 12:30:00}})",
2164- "help_macros_34": "距离上次用户消息发送的时间",
2165- "help_macros_35": "为 AI 设置行为偏差,直到下一个用户输入。文本周围的引号很重要。",
2166- "help_macros_36": "掷骰子。(例如:",
2167- "space_ will roll a 6-sided dice and return a number between 1 and 6)": "将掷一个 6 面骰子并返回 1 到 6 之间的数字)",
2168- "help_macros_37": "从列表中返回一个随机项目。(例如:",
2169- "space_ will return 1 of the 4 numbers at random. Works with text lists too.": "将随机返回 4 个数字中的 1 个。也适用于文本列表。",
2170- "help_macros_38": "随机的替代语法允许在列表项中使用逗号。",
2171- "help_macros_39": "从列表中随机挑选一项。工作原理与 {{random}} 相同,具有相同的语法选项,但一旦挑选,挑选将一直持续到本次聊天,不会在连续消息和提示处理中重新滚动。",
2172- "help_macros_40": "如果使用文本生成 WebUI 后端,则动态地将引号中的文本添加到禁用单词序列中。对其他后端不执行任何操作。可以在任何地方使用(角色描述、WI、AN 等)。文本周围的引号很重要。",
2173- "help_macros_isMobile": "当为移动端时为\"true\",反之为\"false\"",
2174- "Instruct Mode and Context Template Macros:": "指导模式和上下文模板宏:",
2175- "(enabled in the Advanced Formatting settings)": "(在高级格式设置中启用)",
2176- "help_macros_41": "令牌中允许的最大提示长度 = (上下文长度 - 响应长度)",
2177- "help_macros_42": "上下文模板示例对话分隔符",
2178- "help_macros_43": "上下文模板聊天开始行",
2179- "help_macros_44": "主系统提示(如果选择,则覆盖字符提示,或 instructSystemPrompt)",
2180- "help_macros_45": "指示系统提示",
2181- "help_macros_46": "指示系统提示前缀序列",
2182- "help_macros_47": "指示系统提示后缀序列",
2183- "help_macros_48": "指示用户前缀序列",
2184- "help_macros_49": "指示用户后缀序列",
2185- "help_macros_50": "指导助理前缀序列",
2186- "help_macros_51": "指导助理后缀序列",
2187- "help_macros_52": "指导助理第一个输出序列",
2188- "help_macros_53": "指导助手最后输出序列",
2189- "help_macros_54": "指示系统消息前缀序列",
2190- "help_macros_55": "指示系统消息后缀序列",
2191- "help_macros_56": "指示系统指令前缀",
2192- "help_macros_57": "指示第一个用户消息填充器",
2193- "help_macros_58": "指示停止顺序",
2194- "help_macros_first_user": "指示用户第一个输入序列",
2195- "help_macros_last_user": "指示用户最后输入序列",
2196- "Chat variables Macros:": "聊天变量宏:",
2197- "Local variables = unique to the current chat": "局部变量 = 当前聊天所独有",
2198- "Global variables = works in any chat for any character": "全局变量 = 适用于任何角色的任何聊天",
2199- "Scoped variables = works in STscript": "范围变量 = 在 STscript 中有效",
2200- "help_macros_59": "替换为局部变量“name”的值",
2201- "help_macros_60": "替换为空字符串,将局部变量“name”设置为“value”",
2202- "help_macros_61": "替换为空字符串,将“increment”的数值添加到局部变量“name”",
2203- "help_macros_62": "替换为变量“name”的值增加 1 的结果",
2204- "help_macros_63": "替换为变量“name”的值减 1 的结果",
2205- "help_macros_64": "替换为全局变量“name”的值",
2206- "help_macros_65": "替换为空字符串,将全局变量“name”设置为“value”",
2207- "help_macros_66": "替换为空字符串,将“increment”的数值添加到全局变量“name”",
2208- "help_macros_67": "替换为全局变量“name”的值增加 1 的结果",
2209- "help_macros_68": "替换为全局变量“name”的值减 1 的结果",
2210- "help_macros_69": "替换为范围变量“name”的值",
2211- "help_macros_70": "用范围变量“name”的索引处的项目值(对于数组/列表或对象/字典)替换",
22122188 "Choose what to export": "选择您想要导出什么:",
22132189 "Choose what to import": "选择您想要导入什么:",
22142190 "If necessary, you can later restore this chat file from the /backups folder": "若需要,您可稍后在 /backups 文件夹中恢复此聊天文件。",
22152191 "Also delete the current chat file": "同时删除当前聊天文件",
22162192 "Persona Lorebook for": "Persona LorebookTa for的角色世界书:",
2217- "persona_world_template_txt": "A selected World Info will be bound to this persona. When generating an AI reply,\n it will be combined with the entries from global, character and chat lorebooks.",
2193+ "persona_world_template_txt": "将世界书绑定到此角色。生成 AI 回复时,\n 它将与全局、角色和聊天世界书中的条目结合使用。",
22182194 "Insert prompt": "插入提示词",
22192195 "Import a prompt list": "导入提示词列表",
22202196 "Export this prompt list": "导出此提示词列表",
@@ -2234,13 +2210,21 @@
22342210 "Don't forget to save a snapshot of your settings before proceeding.": "在继续之前,不要忘记保存您的设置快照。",
22352211 "Enter your password below to confirm:": "输入您的密码以确认:",
22362212 "Reset custom sampler selection": "重置自定义采样器选择",
2213+ "Reset": "重置",
2214+ "Prioritize showing samplers manually selected from this popup.": "优先显示从此弹出窗口手动选择的采样器。",
2215+ "Toggle on to force the samplers selected in this menu to be shown when switching to the current API Type (API Connections Panel). By default, SillyTavern automatically selects samplers used or needed by the selected API Type.": "开启此选项,以在切换到当前 API 配置(API 连接面板)时强制显示现在所选择的采样器。默认情况下,SillyTavern 会自动选择所选 API 配置使用或需要的采样器。",
22372216 "Here you can toggle the display of individual samplers. (WIP)": "在此可以切换单个采样器的显示。(开发中)",
22382217 "Chat ScenarioCharacter Settings Override": "聊天场景覆盖聊天角色设置覆盖",
22392218 "Remove": "移除",
22402219 "Unique to this chat.": "仅对此聊天生效。",
22412220 "All group members will use the following scenario textvalues instead of what is specified in their character cards.": "All group members will use the following scenario text instead of what is specified in their character cards.所有群成员将使用以下值,而不是其角色卡中指定的值。",
22422221 "The following scenario textvalues will be used instead of the value set in the character card.": "The following scenario text will be used instead of the value set in the character card.以下值将替代角色卡中设置的值。",
22432222 "Checkpoints inherit the scenario overrideoverrides from their parent, and can be changed individually after that.": "Checkpoints inherit the scenario override from their parent, and can be changed individually after that.检查点继承其父级的覆盖设置,并可后续单独更改。",
2223+ "Type Scenario here...": "在此输入场景...",
2224+ "Type Example Messages here...": "在此输入示例消息...",
2225+ "Prefer Char. Prompt": "优先角色提示词",
2226+ "MUST be enabled!": "必须启用!",
2227+ "Type System Prompt here...": "在此输入系统提示词...",
22442228 "API:": "API:",
22452229 "Key:": "密钥:",
22462230 "Add Secret": "添加密钥",
@@ -2256,7 +2240,7 @@
22562240 "Extra parameters for downloading/HuggingFace API": "下载/HuggingFace API 的额外参数。如果不确定,请将其留空。",
22572241 "Revision": "修订",
22582242 "Folder Name": "输出文件夹名称",
22592243 "HF Token": "HF代币HF 令牌",
22602244 "Include Patterns": "包含模式",
22612245 "Glob patterns of files to include in the download.": "要包含在下载中的文件的全局模式。每个模式用换行符分隔。",
22622246 "Exclude Patterns": "排除模式",
@@ -2267,14 +2251,12 @@
22672251 "Save your tags to a file": "将标签保存为文件",
22682252 "Restore tags from a file": "从文件中恢复标签",
22692253 "Create a new tag": "新建一个标签",
2270- "Drag handle to reorder. Click name to rename. Click color to change display.": "拖拽左侧三条横线以排序,点击名字以重命名,点击调色盘以切换颜色。",
2271- "Click on the folder icon to use this tag as a folder.": "点击文件夹图标来将此标签作为一个文件夹。",
2272- "Use alphabetical sorting": "按字母顺序排列",
22732254 "Sort mode": "排序模式",
22742255 "Manual (Drag & Drop)": "手动 (拖放)",
22752256 "Alphabetical (A-Z)": "按字母 (A-Z)",
22762257 "Most Used (By Count)": "按使用次数",
2277- "tags_sorting_desc": "启用后,标签在创建或重命名时会自动按字母顺序排序。\n禁用后,新标签会追加到末尾。\n\n如果通过拖动手动重新排列标签,则自动排序将被禁用。",
2258+ "Drag handle to reorder. Click name to rename. Click color to change display.": "拖拽左侧三条横线以排序,点击名字以重命名,点击调色盘以切换颜色。",
2259+ "Click on the folder icon to use this tag as a folder.": "点击文件夹图标来将此标签作为一个文件夹。",
22782260 "Are you sure you want to delete the theme?": "你确定要删除这个主题吗?",
22792261 "Hi,": "嗨,",
22802262 "To enable multi-account features, restart the SillyTavern server with": "要启用多帐户功能,请使用以下命令重新启动 SillyTavern 服务器",
@@ -2297,7 +2279,7 @@
22972279 "Wipe all user data and reset your account to factory settings.": "删除所有用户数据并将您的账号重置为默认设置。",
22982280 "Reset Everything": "重置一切",
22992281 "This will delete all your settings and data. There will be no undo button. Make sure you have a backup before proceeding.": "这将删除您所有的设置和数据,不可撤销。请确保您已备份数据。",
23002282 "Account reset code has been posted to the server console.": "账户重置代码已发布到服务器控制台账户重置代码已发送至服务器控制台。",
23012283 "Reset Code:": "重置代码:",
23022284 "Want to update?": "获取最新版本",
23032285 "How to start chatting?": "如何快速开始聊天?",
public/script.js+541 -477
@@ -8,6 +8,7 @@ import {
88 Popper,
99 initLibraryShims,
1010 default as libs,
11+ lodash,
1112} from './lib.js';
1213
1314import { humanizedDateTime, favsToHotswap, getMessageTimeStamp, dragElement, isMobile, initRossMods } from './scripts/RossAscends-mods.js';
@@ -188,6 +189,7 @@ import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids,
188189
189190import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';
190191import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
192+import { initMacroAutoComplete } from './scripts/autocomplete/MacroAutoComplete.js';
191193import {
192194 tag_map,
193195 tags,
@@ -271,7 +273,7 @@ import { extractReasoningFromData, extractReasoningSignatureFromData, initReason
271273import { accountStorage } from './scripts/util/AccountStorage.js';
272274import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
273275import { initDataMaid } from './scripts/data-maid.js';
274276import { clearItemizedPrompts, deleteItemizedPromptForMessage, deleteItemizedPrompts, findItemizedPromptSet, initItemizedPrompts, itemizedParams, itemizedPrompts, loadItemizedPrompts, promptItemize, replaceItemizedPromptText, saveItemizedPrompts, swapItemizedPrompts } from './scripts/itemized-prompts.js';
275277import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
276278import { event_types, eventSource } from './scripts/events.js';
277279import { initAccessibility } from './scripts/a11y.js';
@@ -282,6 +284,7 @@ import { AudioPlayer } from './scripts/audio-player.js';
282284import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js';
283285import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
284286import { addChatBackupsBrowser } from './scripts/chat-backups.js';
287+import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
285288
286289// API OBJECT FOR EXTERNAL WIRING
287290globalThis.SillyTavern = {
@@ -386,7 +389,7 @@ let chatSaveTimeout;
386389let importFlashTimeout;
387390export let isChatSaving = false;
388391let firstRun = false;
389392export let settingsReady = false;
390393let currentVersion = '0.0.0';
391394export let displayVersion = 'SillyTavern';
392395
@@ -701,7 +704,6 @@ async function firstLoadInit() {
701704 initDynamicStyles();
702705 initTags();
703706 initBookmarks();
704- initMacros();
705707 await getUserAvatars(true, user_avatar);
706708 await getCharacters();
707709 await getBackgrounds();
@@ -710,6 +712,7 @@ async function firstLoadInit() {
710712 initAuthorsNote();
711713 await initPersonas();
712714 await initSlashCommandAutoComplete();
715+ initMacroAutoComplete();
713716 initWorldInfo();
714717 initHorde();
715718 initRossMods();
@@ -729,6 +732,7 @@ async function firstLoadInit() {
729732 initAccessibility();
730733 addDebugFunctions();
731734 doDailyExtensionUpdatesCheck();
735+ await eventSource.emit(event_types.APP_INITIALIZED);
732736 await hideLoader();
733737 await fixViewport();
734738 await eventSource.emit(event_types.APP_READY);
@@ -833,13 +837,14 @@ export async function selectCharacterById(id, { switchMenu = true } = {}) {
833837 if (selected_group || String(this_chid) !== String(id)) {
834838 //if clicked on a different character from what was currently selected
835839 if (!is_send_press) {
836840 await clearChatsetCharacterId(undefined);
837841 cancelTtsPlaysetCharacterName('');
838842 resetSelectedGroup();
843+ await clearChat({ clearData: true });
844+ cancelTtsPlay();
839845 this_edit_mes_id = undefined;
840846 selected_button = 'character_edit';
841847 setCharacterId(id);
842- chat.length = 0;
843848 chat_metadata = {};
844849 await getChat();
845850 }
@@ -952,7 +957,8 @@ export async function printCharacters(fullRefresh = false) {
952957
953958 // We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date
954959 printTagFilters(tag_filter_type.character);
955960 printTagFilters(tag_filter_type.group_membergroup_members_list);
961+ printTagFilters(tag_filter_type.group_candidates_list);
956962
957963 // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise
958964 applyTagsOnCharacterSelect();
@@ -1175,8 +1181,8 @@ export async function getOneCharacter(avatarUrl) {
11751181
11761182 if (response.ok) {
11771183 const getData = await response.json();
11781184 getData['.name'] = DOMPurify.sanitize(getData['.name']);
11791185 getData['.chat'] = String(getData['.chat']);
11801186
11811187 const indexOf = characters.findIndex(x => x.avatar === avatarUrl);
11821188
@@ -1188,7 +1194,7 @@ export async function getOneCharacter(avatarUrl) {
11881194 }
11891195}
11901196
11911197export function getCharacterSource(chId = this_chid) {
11921198 const character = characters[chId];
11931199
11941200 if (!character) {
@@ -1247,14 +1253,14 @@ export async function getCharacters() {
12471253 const getData = await response.json();
12481254 for (let i = 0; i < getData.length; i++) {
12491255 characters[i] = getData[i];
12501256 characters[i]['.name'] = DOMPurify.sanitize(characters[i]['.name']);
12511257
12521258 // For dropped-in cards
12531259 if (!characters[i]['.chat']) {
12541260 characters[i]['.chat'] = `${characters[i]['.name']} - ${humanizedDateTime()}`;
12551261 }
12561262
12571263 characters[i]['.chat'] = String(characters[i]['.chat']);
12581264 }
12591265
12601266 if (previousAvatar) {
@@ -1346,8 +1352,7 @@ export async function deleteCharacterChatByName(characterId, fileName) {
13461352}
13471353
13481354export async function replaceCurrentChat() {
13491355 await clearChat({ clearData: true });
1350- chat.length = 0;
13511356
13521357 const chatsResponse = await fetch('/api/characters/chats', {
13531358 method: 'POST',
@@ -1390,18 +1395,26 @@ export async function showMoreMessages(messagesToLoad = null) {
13901395
13911396 console.debug('Inserting messages before', messageId, 'count', count, 'chat length', chat.length);
13921397 const prevHeight = chatElement.prop('scrollHeight');
13931398 const isButtonInViewshowMoreButton = isElementInViewport($('#show_more_messages')[0]);
1394-
1399+ const isButtonInView = isElementInViewport(showMoreButton[0]);
1395- while (messageId > 0 && count > 0) {
1400+
13961401 let const newMessageIdfirstId = clamp(messageId - 1count, 0, Infinity);
1397- addOneMessage(chat[newMessageId], { insertBefore: messageId >= chat.length ? null : messageId, scroll: false, forceId: newMessageId, showSwipes: false });
1402+ const messageElements = [];
1398- count--;
1403+ chat.slice(firstId, messageId).forEach((message, id) => {
1399- messageId--;
1404+ messageElements.push(updateMessageElement(message, { messageId: firstId + id }));
1405+ });
1406+ // This could be faster: https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement
1407+ // Fallback to chatElement if the button isn't where it's expected to be.
1408+ if (showMoreButton[0]) {
1409+ showMoreButton.after(messageElements);
1410+ } else {
1411+ chatElement.prepend(messageElements);
14001412 }
1413+
14011414 refreshSwipeButtons();
14021415
14031416 if (messageIdfirstId === 0) {
14041417 $('#show_more_messages')showMoreButton.remove();
14051418 }
14061419
14071420 if (isButtonInView) {
@@ -1422,19 +1435,54 @@ export async function printMessages() {
14221435 chatElement.append('<div id="show_more_messages">Show more messages</div>');
14231436 }
14241437
1425- for (let i = startIndex; i < chat.length; i++) {
1438+ await redisplayChat({ startIndex, fade: false });
1426- const item = chat[i];
1427- addOneMessage(item, { scroll: false, forceId: i, showSwipes: false });
1428- }
14291439
1430- chatElement.find('.mes').removeClass('last_mes');
1431- chatElement.find('.mes').last().addClass('last_mes');
1432- refreshSwipeButtons(false, false);
1433- applyStylePins();
14341440 scrollChatToBottom({ waitForFrame: true });
14351441 delay(debounce_timeout.short).then(() => scrollOnMediaLoad());
14361442}
14371443
1444+/**
1445+ * Visually updates all chat messages including and after index by removing them, then adding them.
1446+ * @param {object} [options] Options
1447+ * @param {ChatMessage[]} [options.targetChat=chat] All messages in chat before startIndex will remain unchanged.
1448+ * @param {Number} [options.startIndex=0] Everything including and after startIndex will be replaced.
1449+ * @param {Boolean} [options.fade=true] When false, the swipe chevrons will not fade in.
1450+ */
1451+export async function redisplayChat({ targetChat = chat, startIndex = 0, fade = true } = {}) {
1452+ const messageElements = chatElement.find('.mes');
1453+ messageElements.removeClass('last_mes');
1454+
1455+ //Remove messages after index.
1456+ messageElements.filter(`.mes[mesid="${startIndex}"]`).nextAll('.mes').addBack().remove();
1457+
1458+ const t1 = performance.now();
1459+
1460+ const messages = targetChat.slice(startIndex);
1461+
1462+ if (messages.length > 0) {
1463+ const newMessageElements = messages.map((message, offset) => {
1464+ const i = startIndex + offset;
1465+ const messageElement = updateMessageElement(message, { messageId: i });
1466+
1467+ return messageElement[0];
1468+ });
1469+
1470+ //The last_mes has been removed, add it to the new last message.
1471+ newMessageElements.at(-1).classList.add('last_mes');
1472+
1473+ //Append to chat in one DOM update.
1474+ chatElement.append(newMessageElements);
1475+
1476+ applyCharacterTagsToMessageDivs({ mesIds: lodash.range(startIndex, targetChat.length, 1) });
1477+ }
1478+
1479+ refreshSwipeButtons(false, fade);
1480+ applyStylePins();
1481+ updateEditArrowClasses();
1482+
1483+ console.info(`Rendered ${targetChat.length - startIndex} messages in ${((performance.now() - t1) / 1000).toFixed(3)} seconds.`);
1484+}
1485+
14381486export function scrollOnMediaLoad() {
14391487 const started = Date.now();
14401488 const media = chatElement.find('.mes_block img, .mes_block video, .mes_block audio').toArray();
@@ -1482,7 +1530,12 @@ export function cancelDebouncedChatSave() {
14821530 }
14831531}
14841532
1485-export async function clearChat() {
1533+/**
1534+ * Visually removes all chat message elements.
1535+ * @param {object} [options] Options
1536+ * @param {boolean} [options.clearData=false] Optionally clear the chat array's contents.
1537+ */
1538+export async function clearChat({ clearData = false } = {}) {
14861539 cancelDebouncedChatSave();
14871540 cancelDebouncedMetadataSave();
14881541 closeMessageEditor();
@@ -1499,9 +1552,12 @@ export async function clearChat() {
14991552
15001553 await saveItemizedPrompts(getCurrentChatId());
15011554 itemizedPrompts.length = 0;
1555+
1556+ if (clearData) chat.length = 0;
15021557}
15031558
15041559export async function deleteLastMessage() {
1560+ deleteItemizedPromptForMessage(chat.length - 1);
15051561 chat.length = chat.length - 1;
15061562 chatElement.children('.mes').last().remove();
15071563 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
@@ -1554,9 +1610,10 @@ export async function deleteMessage(id, swipeDeletionIndex = undefined, askConfi
15541610 chat.splice(id, 1);
15551611 messageElement.remove();
15561612
15571613 chat_metadata['.tainted'] = true;
15581614
15591615 const startIndex = [0, minId].includes(id) ? id : null;
1616+ deleteItemizedPromptForMessage(id);
15601617 updateViewMessageIds(startIndex);
15611618 saveChatDebounced();
15621619
@@ -1569,10 +1626,17 @@ export async function deleteMessage(id, swipeDeletionIndex = undefined, askConfi
15691626 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
15701627}
15711628
1572-export async function reloadCurrentChat() {
1629+export const reloadChatMutex = new SimpleMutex(reloadCurrentChatUnsafe);
1630+export const reloadCurrentChat = reloadChatMutex.update.bind(reloadChatMutex);
1631+
1632+/**
1633+ * Reloads the current chat unsafely, without mutex protection.
1634+ * Use `reloadCurrentChat` instead to ensure thread safety.
1635+ * @returns {Promise<void>} A promise that resolves when the chat is reloaded.
1636+ */
1637+export async function reloadCurrentChatUnsafe() {
15731638 preserveNeutralChat();
15741639 await clearChat({ clearData: true });
1575- chat.length = 0;
15761640
15771641 if (selected_group) {
15781642 await getGroupChat(selected_group, true);
@@ -1610,13 +1674,14 @@ export async function sendTextareaMessage() {
16101674 // "Continue on send" is activated when the user hits "send" (or presses enter) on an empty chat box, and the last
16111675 // message was sent from a character (not the user or the system).
16121676 const textareaText = String($('#send_textarea').val());
1677+ const lastMessage = chat[chat.length - 1];
16131678 if (power_user.continue_on_send &&
16141679 !hasPendingFileAttachment() &&
16151680 !textareaText &&
16161681 !selected_group &&
16171682 chat.length &&
16181683 !chat[chatlastMessage.length - 1]['is_user'] &&
1619- !chat[chat.length - 1]['is_system']
1684+ !lastMessage.is_system
16201685 ) {
16211686 generateType = 'continue';
16221687 }
@@ -1637,7 +1702,7 @@ export async function sendTextareaMessage() {
16371702 * @param {boolean} isSystem If the message was sent by the system
16381703 * @param {boolean} isUser If the message was sent by the user
16391704 * @param {number} messageId Message index in chat array
16401705 * @param {objectPartial<DOMPurify.Config>} [sanitizerOverrides] DOMPurify sanitizer option overrides
16411706 * @param {boolean} [isReasoning] If the message is reasoning output
16421707 * @returns {string} HTML string
16431708 */
@@ -1786,7 +1851,7 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
17861851 mes = mes.replace(new RegExp(`(^|\n)${escapeRegex(ch_name)}:`, 'g'), '$1');
17871852 }
17881853
17891854 /** @type {import('dompurify')DOMPurify.Config & { RETURN_DOM_FRAGMENT: false; RETURN_DOM: false }} */
17901855 const config = {
17911856 RETURN_DOM: false,
17921857 RETURN_DOM_FRAGMENT: false,
@@ -1810,9 +1875,7 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
18101875 * the value in `extra.api`.
18111876 *
18121877 * @param {JQuery<HTMLElement>} mes - The message element containing the timestamp where the icon should be inserted or replaced.
18131878 * @param {ObjectChatMessageExtra} extra - Contains the API and model details.
1814- * @param {string} extra.api - The name of the API, used to determine which SVG to fetch.
1815- * @param {string} extra.model - The model name, used to check for the substring "claude".
18161879 */
18171880function insertSVGIcon(mes, extra) {
18181881 // Determine the SVG filename
@@ -1860,56 +1923,6 @@ function insertSVGIcon(mes, extra) {
18601923 createModelImage('thinking-icon', '.mes_reasoning_header_title', true);
18611924}
18621925
1863-
1864-function getMessageFromTemplate({
1865- mesId,
1866- swipeId,
1867- characterName,
1868- isUser,
1869- avatarImg,
1870- bias,
1871- isSystem,
1872- title,
1873- timerValue,
1874- timerTitle,
1875- bookmarkLink,
1876- forceAvatar,
1877- timestamp,
1878- tokenCount,
1879- extra,
1880- type,
1881-}) {
1882- const mes = messageTemplate.clone();
1883- mes.attr({
1884- 'mesid': mesId,
1885- 'swipeid': swipeId,
1886- 'ch_name': characterName,
1887- 'is_user': isUser,
1888- 'is_system': !!isSystem,
1889- 'bookmark_link': bookmarkLink,
1890- 'force_avatar': !!forceAvatar,
1891- 'timestamp': timestamp,
1892- ...(type ? { type } : {}),
1893- });
1894- mes.find('.avatar img').attr('src', avatarImg);
1895- mes.find('.ch_name .name_text').text(characterName);
1896- mes.find('.mes_bias').html(bias);
1897- mes.find('.timestamp').text(timestamp).attr('title', `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`);
1898- mes.find('.mesIDDisplay').text(`#${mesId}`);
1899- tokenCount && mes.find('.tokenCounterDisplay').text(`${tokenCount}t`);
1900- title && mes.attr('title', title);
1901- timerValue && mes.find('.mes_timer').attr('title', timerTitle).text(timerValue);
1902- bookmarkLink && updateBookmarkDisplay(mes);
1903-
1904- updateReasoningUI(mes);
1905-
1906- if (power_user.timestamp_model_icon && extra?.api) {
1907- insertSVGIcon(mes, extra);
1908- }
1909-
1910- return mes;
1911-}
1912-
19131926/**
19141927 * Re-renders a message block with updated content.
19151928 * @param {number} messageId Message ID
@@ -2382,183 +2395,214 @@ export function addCopyToCodeBlocks(messageElement) {
23822395 }
23832396}
23842397
2398+/**
2399+ * Shows or hides the Prompt display button
2400+ * @param {ChatMessage} message Message object
2401+ * @param {object} options Options
2402+ * @param {number} [options.messageId] Message ID
2403+ * @param {JQuery<HTMLElement>} [options.messageElement] Message element
2404+ * @return {void}
2405+ */
2406+function updateMessageItemizedPromptButton(message, { messageId = chat.indexOf(message), messageElement = chatElement.find(`.mes[mesid="${messageId}"]`) }) {
2407+ //if we have itemized messages, and the array isn't null..
2408+ if (!message.is_user && Array.isArray(itemizedPrompts) && itemizedPrompts.length > 0) {
2409+ const itemizedPrompt = itemizedPrompts.find(x => Number(x.mesId) === Number(messageId));
2410+ if (itemizedPrompt) {
2411+ messageElement.find('.mes_prompt').show();
2412+ }
2413+ }
2414+}
2415+
2416+/**
2417+ * Gets messageFormatting for a ChatMessage object.
2418+ * @param {ChatMessage} message
2419+ * @param {object} options Options
2420+ * @param {number} [options.messageId] Message ID
2421+ * @returns {string} Formatted message HTML
2422+ */
2423+function getMessageTextHTML(message, { messageId = chat.indexOf(message) }) {
2424+ // if mes.extra.uses_system_ui is true, set an override on the sanitizer options
2425+ /** @type {Partial<DOMPurify.Config>} */
2426+ const sanitizerOverrides = message.extra?.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {};
2427+
2428+ return messageFormatting(
2429+ message.extra?.display_text || message.mes,
2430+ message.name,
2431+ message.is_system,
2432+ message.is_user,
2433+ messageId,
2434+ sanitizerOverrides,
2435+ false,
2436+ );
2437+}
23852438
23862439/**
23872440 * Adds a single message to the chat.
23882441 * @param {ChatMessage} mes Message object
23892442 * @param {object} [options] Options
23902443 * @param {string} [options.type=undefined|'normalswipe'] MessageDeprecated. typeUse updateMessageElement instead.
23912444 * @param {number} [options.insertAfter=null] Message ID to insert the new message after
23922445 * @param {boolean} [options.scroll=true] Whether to scroll to the new message
23932446 * @param {number} [options.insertBefore=null] Message ID to insert the new message before
23942447 * @param {number} [options.forceId=null] Force the message ID
23952448 * @param {boolean} [options.showSwipes=true] Whether to refresh the swipe buttons.
23962449 * @returns {voidJQuery<HTMLElement>} The newly added message element
23972450 */
23982451export function addOneMessage(mes, { type = 'normal'undefined, insertAfter = null, scroll = true, insertBefore = null, forceId = null, showSwipes = true } = {}) {
2399- let messageText = mes['mes'];
2452+ // Callers push the new message to chat before calling addOneMessage
24002453 const momentDatemessageId = timestampToMoment(mes.send_date(); => {
2401- const timestamp = momentDate.isValid() ? momentDate.format('LL LT') : '';
2454+ if (typeof forceId === 'number') {
2455+ return forceId;
2456+ }
2457+ if (typeof insertBefore === 'number') {
2458+ return insertBefore - 1;
2459+ }
2460+ if (typeof insertAfter === 'number') {
2461+ return insertAfter + 1;
2462+ }
2463+ const index = chat.indexOf(mes);
2464+ if (index !== -1) {
2465+ return index;
2466+ }
2467+ return chat.length - 1;
2468+ })();
2469+
2470+ let messageElement;
24022471
2403- if (mes?.extra?.display_text) {
2472+ if (type === 'swipe') {
2404- messageText = mes.extra.display_text;
2473+ // Forbidden black magic
2474+ // This allows to use "continue" on user messages
2475+ mes.swipe_id ??= 0;
2476+ mes.swipes ??= [mes.mes];
2477+ //This keeps listeners intact.
2478+ messageElement = chatElement.find(`[mesid="${messageId}"]`);
2479+ updateMessageElement(mes, { messageId, messageElement, adjustMediaScroll: scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE });
2480+ } else {
2481+ messageElement = updateMessageElement(mes, { messageId, adjustMediaScroll: scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE });
2482+ if (typeof insertAfter === 'number' && insertAfter >= 0) {
2483+ const target = chatElement.find(`.mes[mesid="${insertAfter}"]`);
2484+ $(messageElement).insertAfter(target);
2485+ } else if (typeof insertBefore === 'number' && insertBefore >= 0) {
2486+ const target = chatElement.find(`.mes[mesid="${insertBefore}"]`);
2487+ $(messageElement).insertBefore(target);
2488+ } else {
2489+ chatElement.append(messageElement);
2490+ }
24052491 }
24062492
2407- // Forbidden black magic
2493+
2408- // This allows to use "continue" on user messages
2494+ //last_mes should always be updated.
2409- if (type === 'swipe' && mes.swipe_id === undefined) {
2495+ chatElement.find('.mes').removeClass('last_mes');
2410- mes.swipe_id = 0;
2496+ chatElement.find('.mes').last().addClass('last_mes');
2411- mes.swipes = [mes.mes];
2497+
2498+ if (showSwipes) refreshSwipeButtons();
2499+ // Don't scroll if not inserting last
2500+ if (!insertAfter && !insertBefore && scroll) {
2501+ scrollChatToBottom({ waitForFrame: true });
24122502 }
24132503
2504+ applyCharacterTagsToMessageDivs({ mesIds: messageId });
2505+ updateEditArrowClasses();
2506+ return messageElement;
2507+}
2508+
2509+/**
2510+ * Creates the element of a single message as if it were the last message or at forceMesId
2511+ * @param {ChatMessage} mes Message object
2512+ * @param {object} [options] Options
2513+ * @param {number} [options.messageId=chat.length - 1] Force the message ID
2514+ * @param {JQuery<HTMLElement>} [options.messageElement=messageTemplate.clone()] This message element will be updated with the ChatMessage object.
2515+ * @param {SCROLL_BEHAVIOR} [options.adjustMediaScroll=SCROLL_BEHAVIOR.NONE] Scroll behavior option passed to appendMediaToMessage.
2516+ * @returns {JQuery<HTMLElement>} Rendered HTMLElement.
2517+ */
2518+export function updateMessageElement(mes, { messageId = chat.length - 1, messageElement = messageTemplate.clone(), adjustMediaScroll = SCROLL_BEHAVIOR.NONE } = {}) {
2519+
24142520 let avatarImg = getThumbnailUrl('persona', user_avatar);
2415- const isSystem = mes.is_system;
2416- const title = mes.title;
24172521
24182522 //for non-user mesagesmessages
24192523 if (!mes['.is_user']) {
24202524 if (mes.force_avatar) {
24212525 avatarImg = mes.force_avatar;
24222526 } else if (this_chid === undefined) {
24232527 avatarImg = system_avatar;
2528+ } else if (characters[this_chid] && characters[this_chid].avatar !== 'none') {
2529+ avatarImg = getThumbnailUrl('avatar', characters[this_chid].avatar);
24242530 } else {
2425- if (characters[this_chid].avatar !== 'none') {
2531+ avatarImg = default_avatar;
2426- avatarImg = getThumbnailUrl('avatar', characters[this_chid].avatar);
2427- } else {
2428- avatarImg = default_avatar;
2429- }
24302532 }
24312533 //old processing:
24322534 //if messgemessage is from sytemsystem, use the name provided in the message JSONL to proceed,
24332535 //if not system message, use name2 (char's name) to proceed
24342536 //characterName = mes.is_system || mes.force_avatar ? mes.name : name2;
24352537 } else if (mes['.is_user'] && mes['.force_avatar']) {
24362538 // Special case for persona images.
24372539 avatarImg = mes['.force_avatar'];
24382540 }
2541+ const momentDate = timestampToMoment(mes.send_date);
2542+ const timestamp = momentDate.isValid() ? momentDate.format('LL LT') : '';
2543+ const messageHTML = getMessageTextHTML(mes, { messageId });
2544+ const bookmarkLink = mes?.extra?.bookmark_link;
2545+ const tokenCount = mes.extra?.token_count;
2546+ const { timerValue, timerTitle } = formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token);
2547+
2548+ messageElement.attr({
2549+ 'mesid': messageId,
2550+ 'swipeid': mes.swipe_id ?? 0,
2551+ 'ch_name': mes.name,
2552+ 'is_user': mes.is_user,
2553+ 'is_system': !!mes.is_system,
2554+ 'bookmark_link': bookmarkLink,
2555+ 'force_avatar': !!mes.force_avatar,
2556+ 'timestamp': timestamp,
2557+ // ...(type ?? { type }),
2558+ 'type': mes.extra?.type ?? '',
2559+ });
24392560
2440- // if mes.extra.uses_system_ui is true, set an override on the sanitizer options
2561+ messageElement.find('.avatar img').attr('src', avatarImg);
2441- const sanitizerOverrides = mes.extra?.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {};
2562+ messageElement.find('.ch_name .name_text').text(mes.name);
2442-
2563+ messageElement.find('.timestamp').text(timestamp).attr('title', `${mes.extra?.api ? mes.extra.api + ' - ' : ''}${mes.extra?.model ?? ''}`);
2443- messageText = messageFormatting(
2564+ messageElement.find('.mesIDDisplay').text(`#${messageId}`);
2444- messageText,
2565+ tokenCount && messageElement.find('.tokenCounterDisplay').text(`${tokenCount}t`);
2445- mes.name,
2566+ mes.title && messageElement.attr('title', mes.title);
2446- isSystem,
2567+ timerValue && messageElement.find('.mes_timer').attr('title', timerTitle).text(timerValue);
2447- mes.is_user,
2568+ bookmarkLink && updateBookmarkDisplay(messageElement);
2448- chat.indexOf(mes),
2449- sanitizerOverrides,
2450- false,
2451- );
2452- const bias = messageFormatting(mes.extra?.bias ?? '', '', false, false, -1, {}, false);
2453- let bookmarkLink = mes?.extra?.bookmark_link ?? '';
2454-
2455- let params = {
2456- mesId: forceId ?? chat.length - 1,
2457- swipeId: mes.swipe_id ?? 0,
2458- characterName: mes.name,
2459- isUser: mes.is_user,
2460- avatarImg: avatarImg,
2461- bias: bias,
2462- isSystem: isSystem,
2463- title: title,
2464- bookmarkLink: bookmarkLink,
2465- forceAvatar: mes.force_avatar,
2466- timestamp: timestamp,
2467- extra: mes.extra,
2468- tokenCount: mes.extra?.token_count ?? 0,
2469- type: mes.extra?.type ?? '',
2470- ...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token),
2471- };
2472-
2473- const renderedMessage = getMessageFromTemplate(params);
24742569
24752570 if (typemes.extra?.bias !== 'swipe') {
2476- if (!insertAfter && !insertBefore) {
2571+ const bias = messageFormatting(mes.extra?.bias, '', false, false, -1, {}, false);
2477- chatElement.append(renderedMessage);
2572+ messageElement.find('.mes_bias').html(bias);
2478- }
2479- else if (insertAfter) {
2480- const target = chatElement.find(`.mes[mesid="${insertAfter}"]`);
2481- $(renderedMessage).insertAfter(target);
2482- } else {
2483- const target = chatElement.find(`.mes[mesid="${insertBefore}"]`);
2484- $(renderedMessage).insertBefore(target);
2485- }
24862573 }
24872574
2488- // Callers push the new message to chat before calling addOneMessage
2575+ updateReasoningUI(messageElement);
2489- const newMessageId = typeof forceId == 'number' ? forceId : chat.length - 1;
24902576
2491- const newMessage = chatElement.find(`[mesid="${newMessageId}"]`);
2577+ if (power_user.timestamp_model_icon && mes.extra?.api) {
2492- const isSmallSys = mes?.extra?.isSmallSys;
2578+ insertSVGIcon(messageElement, mes.extra);
2579+ }
24932580
24942581 if (mes?.extra?.isSmallSys === true) {
24952582 newMessagemessageElement.addClass('smallSysMes');
24962583 }
24972584
24982585 if (Array.isArray(mes?.extra?.tool_invocations)) {
24992586 newMessagemessageElement.addClass('toolCall');
25002587 }
25012588
2502- //shows or hides the Prompt display button
2589+ updateMessageItemizedPromptButton(mes, { messageId, messageElement });
2503- let mesIdToFind = type === 'swipe' ? params.mesId - 1 : params.mesId; //Number(newMessage.attr('mesId'));
25042590
2505- //if we have itemized messages, and the array isn't null..
2591+ messageElement.find('.avatar img').on('error', function () {
2506- if (params.isUser === false && Array.isArray(itemizedPrompts) && itemizedPrompts.length > 0) {
2507- const itemizedPrompt = itemizedPrompts.find(x => Number(x.mesId) === Number(mesIdToFind));
2508- if (itemizedPrompt) {
2509- newMessage.find('.mes_prompt').show();
2510- }
2511- }
2512-
2513- newMessage.find('.avatar img').on('error', function () {
25142592 $(this).hide();
25152593 $(this).parent().html('<div class="missing-avatar fa-solid fa-user-slash"></div>');
25162594 });
25172595
2518- if (type === 'swipe') {
2596+ appendMediaToMessage(mes, messageElement, adjustMediaScroll);
2519- const swipeMessage = chatElement.find(`[mesid="${newMessageId}"]`);
2597+ messageElement.find('.mes_text').html(messageHTML);
2520- swipeMessage.attr('swipeid', params.swipeId);
2598+ addCopyToCodeBlocks(messageElement);
2521- swipeMessage.find('.mes_text').html(messageText).attr('title', title);
2522- swipeMessage.find('.timestamp').text(timestamp).attr('title', `${params.extra.api} - ${params.extra.model}`);
2523- updateReasoningUI(swipeMessage);
2524- appendMediaToMessage(mes, swipeMessage, scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE);
2525- if (power_user.timestamp_model_icon && params.extra?.api) {
2526- insertSVGIcon(swipeMessage, params.extra);
2527- }
2528-
2529- if (mes.swipe_id == mes.swipes.length - 1) {
2530- swipeMessage.find('.mes_timer').text(params.timerValue).attr('title', params.timerTitle);
2531- swipeMessage.find('.tokenCounterDisplay').text(`${params.tokenCount}t`);
2532- } else {
2533- swipeMessage.find('.mes_timer').empty();
2534- swipeMessage.find('.tokenCounterDisplay').empty();
2535- }
2536- } else {
2537- chatElement.find(`[mesid="${newMessageId}"] .mes_text`).append(messageText);
2538- appendMediaToMessage(mes, newMessage, scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE);
2539- }
2540-
2541- addCopyToCodeBlocks(newMessage);
25422599
25432600 // Set the swipes counter for all non-user messages.
25442601 if (!paramsmes.isUseris_user) {
2545- updateSwipeCounter(newMessageId);
2602+ updateSwipeCounter(messageId, { message: mes, messageElement });
2546- }
2547-
2548- //last_mes should always be updated.
2549- chatElement.find('.mes').removeClass('last_mes');
2550- chatElement.find('.mes').last().addClass('last_mes');
2551- if (showSwipes) {
2552- refreshSwipeButtons();
2553- }
2554-
2555- // Don't scroll if not inserting last
2556- if (!insertAfter && !insertBefore && scroll) {
2557- scrollChatToBottom({ waitForFrame: true });
25582603 }
25592604
2560- applyCharacterTagsToMessageDivs({ mesIds: newMessageId });
2605+ return messageElement;
2561- updateEditArrowClasses();
25622606}
25632607
25642608/**
@@ -2703,6 +2747,20 @@ export function substituteParamsLegacy(content, _name1, _name2, _original, _grou
27032747 });
27042748 }
27052749
2750+ // Try to roughly detect experimental macro features to show the onboarding if needed.
2751+ // This does not have to be 100% accurate, only best effort what we can quickly check.
2752+ // Only do this if the warning wasn't shown yet, to prevent needless regex checks.
2753+ if (accountStorage.getItem('slash_command_experimental_engine_warning_shown') !== 'true') {
2754+ let feature = /** @type {string|null} */ (null);
2755+ if (/{{\s*if/.test(content)) feature = '{{if}} macro';
2756+ else if (/{{\s*\//.test(content)) feature = 'scoped macro';
2757+ else if (/{{\s*[!?~#/]/.test(content)) feature = 'macro flags';
2758+ else if (/{{\s*[.$]/.test(content)) feature = 'variable shorthands';
2759+ else if (/\{\{(?:(?!\}\}).)*\{\{(?=[\s\S]*?\}\}[\s\S]*?\}\})/.test(content)) feature = 'nested macro';
2760+
2761+ if (feature) void onboardingExperimentalMacroEngine(feature);
2762+ }
2763+
27062764 const environment = {};
27072765
27082766 if (typeof _original === 'string') {
@@ -2816,7 +2874,7 @@ export function substituteParamsLegacy(content, _name1, _name2, _original, _grou
28162874 * @param {string} [options.original] - The original message for {{original}} substitution.
28172875 * @param {string} [options.groupOverride] - The group members list for {{group}} substitution.
28182876 * @param {boolean} [options.replaceCharacterCard=true] - Whether to replace character card macros.
28192877 * @param {Record<string,string|MacroHandler import('./scripts/macros/engine/MacroEnv.types.js').DynamicMacroValue>} [options.dynamicMacros={}] - Additional environment variables as dynamic macros for substitution. Registered as macro functions.
28202878 * @param {(x: string) => string} [options.postProcessFn=(x) => x] - Post-processing function for each substituted macro.
28212879 * @returns {string} The string with substituted parameters.
28222880 */
@@ -3241,7 +3299,7 @@ export function getCharacterCardFieldsLazy({ chid = undefined } = {}) {
32413299 persona: () => baseChatReplace(power_user.persona_description?.trim()),
32423300 system: () => {
32433301 if (!character) return '';
32443302 const systemPrompt = chat_metadata['.system_prompt'] || character.data?.system_prompt || '';
32453303 return power_user.prefer_character_prompt ? baseChatReplace(systemPrompt.trim()) : '';
32463304 },
32473305 jailbreak: () => {
@@ -3271,13 +3329,13 @@ export function getCharacterCardFieldsLazy({ chid = undefined } = {}) {
32713329 scenario: () => {
32723330 if (groupCardsLazy) return groupCardsLazy.scenario;
32733331 if (!character) return '';
32743332 const scenarioText = chat_metadata['.scenario'] || character.scenario || '';
32753333 return baseChatReplace(scenarioText.trim());
32763334 },
32773335 mesExamples: () => {
32783336 if (groupCardsLazy) return groupCardsLazy.mesExamples;
32793337 if (!character) return '';
32803338 const exampleDialog = chat_metadata['.mes_example'] || character.mes_example || '';
32813339 return baseChatReplace(exampleDialog.trim());
32823340 },
32833341 };
@@ -3492,39 +3550,39 @@ class StreamingProcessor {
34923550 this.sendTextarea.value = processedText;
34933551 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
34943552 } else {
34953553 const mesChanged = chat[messageId]['.mes'] !== processedText;
34963554 await this.#checkDomElements(messageId);
34973555 this.#updateMessageBlockVisibility();
34983556 const currentTime = new Date();
34993557 chat[messageId]['.mes'] = processedText;
35003558 chat[messageId]['.gen_started'] = this.timeStarted;
35013559 chat[messageId]['.gen_finished'] = currentTime;
35023560 if (!chat[messageId]['.extra']) {
35033561 chat[messageId]['.extra'] = {};
35043562 }
35053563 chat[messageId]['.extra']['.time_to_first_token'] = this.timeToFirstToken;
35063564
35073565 // Update reasoning
35083566 await this.reasoningHandler.process(messageId, mesChanged, this.promptReasoning);
35093567 processedText = chat[messageId]['.mes'];
35103568
35113569 // Token count update.
35123570 const tokenCountText = this.reasoningHandler.reasoning + processedText;
35133571 const currentTokenCount = isFinal && power_user.message_token_count_enabled ? await getTokenCountAsync(tokenCountText, 0) : 0;
35143572 if (currentTokenCount) {
35153573 chat[messageId]['.extra']['.token_count'] = currentTokenCount;
35163574 if (this.messageTokenCounterDom instanceof HTMLElement) {
35173575 this.messageTokenCounterDom.textContent = `${currentTokenCount}t`;
35183576 }
35193577 }
35203578
35213579 if ((this.type == 'swipe' || this.type === 'continue') && Array.isArray(chat[messageId]['.swipes'])) {
35223580 chat[messageId]['.swipes'][chat[messageId]['.swipe_id']] = processedText;
35233581 chat[messageId]['.swipe_info'][chat[messageId]['.swipe_id']] = {
35243582 'send_date': chat[messageId]['.send_date'],
35253583 'gen_started': chat[messageId]['.gen_started'],
35263584 'gen_finished': chat[messageId]['.gen_finished'],
35273585 'extra': structuredClone(chat[messageId]['.extra']),
35283586 };
35293587 }
35303588
@@ -3633,13 +3691,13 @@ class StreamingProcessor {
36333691
36343692 setFirstSwipe(messageId) {
36353693 if (this.type !== 'swipe' && this.type !== 'impersonate') {
36363694 if (Array.isArray(chat[messageId]['.swipes']) && chat[messageId]['.swipes'].length === 1 && chat[messageId]['.swipe_id'] === 0) {
36373695 chat[messageId]['.swipes'][0] = chat[messageId]['.mes'];
36383696 chat[messageId]['.swipe_info'][0] = {
36393697 'send_date': chat[messageId]['.send_date'],
36403698 'gen_started': chat[messageId]['.gen_started'],
36413699 'gen_finished': chat[messageId]['.gen_finished'],
36423700 'extra': structuredClone(chat[messageId]['.extra']),
36433701 };
36443702 }
36453703 }
@@ -4082,7 +4140,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
40824140 const isInstruct = power_user.instruct.enabled && main_api !== 'openai';
40834141 const isImpersonate = type == 'impersonate';
40844142
40854143 if (!(dryRun || depth || type == 'regenerate' || type == 'swipe' || type == 'quiet')) {
40864144 const interruptedByCommand = await processCommands(String($('#send_textarea').val()));
40874145
40884146 if (interruptedByCommand) {
@@ -4119,7 +4177,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
41194177 // Hide swipes if not in a dry run.
41204178 hideSwipeButtons();
41214179 // If generated any message, set the flag to indicate it can't be recreated again.
41224180 chat_metadata['.tainted'] = true;
41234181 }
41244182
41254183 if (selected_group && !is_group_generating) {
@@ -4168,17 +4226,20 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
41684226 return Promise.resolve();
41694227 }
41704228
4229+ const lastMessage = chat[chat.length - 1];
4230+
41714231 let textareaText;
41724232 if (type !== 'regenerate' && type !== 'swipe' && type !== 'quiet' && !isImpersonate && !dryRun && !depth) {
41734233 is_send_press = true;
41744234 textareaText = String($('#send_textarea').val());
41754235 $('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true }));
41764236 } else {
41774237 textareaText = '';
41784238 if (chat.length && chat[chatlastMessage.length - 1]['is_user']) {
41794239 //do nothing? why does this check exist?
41804240 }
41814241 else if (type !== 'quiet' && type !== 'swipe' && !isImpersonate && !dryRun && !depth && chat.length) {
4242+ deleteItemizedPromptForMessage(chat.length - 1);
41824243 chat.length = chat.length - 1;
41834244 await removeLastMessage();
41844245 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
@@ -4189,13 +4250,13 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
41894250
41904251 // Rewrite the generation timer to account for the time passed for all the continuations.
41914252 if (isContinue && chat.length) {
41924253 const prevFinished = chat[chatlastMessage.length - 1]['gen_finished'];
41934254 const prevStarted = chat[chatlastMessage.length - 1]['gen_started'];
41944255
41954256 if (prevFinished && prevStarted) {
41964257 const timePassed = Number(prevFinished) - Number(prevStarted);
41974258 generation_started = new Date(Date.now() - timePassed);
41984259 chat[chatlastMessage.length - 1]['gen_started'] = generation_started;
41994260 }
42004261 }
42014262
@@ -4218,7 +4279,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
42184279 'continue',
42194280 ];
42204281 //for normal messages sent from user..
42214282 if ((textareaText != '' || (hasPendingFileAttachment() && !noAttachTypes.includes(type))) && !automatic_trigger && type !== 'quiet' && !dryRun && !depth) {
42224283 // If user message contains no text other than bias - send as a system message
42234284 if (messageBias && !removeMacros(textareaText)) {
42244285 sendSystemMessage(system_message_types.GENERIC, ' ', { bias: messageBias });
@@ -4227,7 +4288,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
42274288 await sendMessageAsUser(textareaText, messageBias);
42284289 }
42294290 }
42304291 else if (textareaText == '' && !automatic_trigger && !dryRun && [undefined, 'normal'].includes(type) && main_api == 'openai' && oai_settings.send_if_empty.trim().length > 0 && !depth) {
42314292 // Use send_if_empty if set and the user message is empty. Only when sending messages normally
42324293 await sendMessageAsUser(oai_settings.send_if_empty.trim(), messageBias);
42334294 }
@@ -4877,7 +4938,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
48774938 let thisPromptContextSize = await getTokenCountAsync(prompt, power_user.token_padding);
48784939
48794940 if (thisPromptContextSize > this_max_context) { //if the prepared prompt is larger than the max context size...
48804941 if (count_exm_add > 0) { // ..and we have example mesagesmessages..
48814942 count_exm_add--; // remove the example messages...
48824943 await checkPromptSize(); // and try agin...
48834944 } else if (mesSend.length > 0) { // if the chat history is longer than 0
@@ -5117,7 +5178,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
51175178 chatInjects: injectedIndices?.map(index => arrMes[arrMes.length - index - 1])?.join('') || '',
51185179 summarizeString: (extension_prompts['1_memory']?.value || ''),
51195180 authorsNoteString: (extension_prompts['2_floating_prompt']?.value || ''),
51205181 smartContextString: (extension_prompts['.chromadb']?.value || ''),
51215182 chatVectorsString: (extension_prompts['3_vectors']?.value || ''),
51225183 dataBankVectorsString: (extension_prompts['4_vectors_data_bank']?.value || ''),
51235184 worldInfoString: worldInfoString,
@@ -5605,7 +5666,7 @@ export function getBiasStrings(textareaText, type) {
56055666function formatMessageHistoryItem(chatItem, isInstruct, forceOutputSequence) {
56065667 const isNarratorType = chatItem?.extra?.type === system_message_types.NARRATOR;
56075668 const characterName = chatItem?.name ? chatItem.name : name2;
56085669 const itemName = chatItem.is_user ? chatItem['.name'] : characterName;
56095670 const shouldPrependName = !isNarratorType;
56105671
56115672 // If this symbol flag is set, completely ignore the message.
@@ -5674,7 +5735,7 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul
56745735 await populateFileAttachment(message);
56755736 statMesProcess(message, 'user', characters, this_chid, '');
56765737
56775738 chat_metadata['.tainted'] = true;
56785739
56795740 if (typeof insertAt === 'number' && insertAt >= 0 && insertAt <= chat.length) {
56805741 chat.splice(insertAt, 0, message);
@@ -5825,7 +5886,7 @@ function setInContextMessages(msgInContextCount, type) {
58255886
58265887 // Update last id to chat. No metadata save on purpose, gets hopefully saved via another call
58275888 const lastMessageId = Math.max(0, chat.length - msgInContextCount);
58285889 chat_metadata['.lastInContextMessageId'] = lastMessageId;
58295890}
58305891
58315892/**
@@ -6364,18 +6425,20 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
63646425 [type, getMessage, fromStreaming, title, swipes, reasoning, imageUrls, reasoningSignature] = arguments;
63656426 }
63666427
6367- if (type != 'append' && type != 'continue' && type != 'appendFinal' && chat.length && (chat[chat.length - 1]['swipe_id'] === undefined ||
6428+ const lastMessage = chat[chat.length - 1];
6368- chat[chat.length - 1]['is_user'])) {
6429+
6430+ if (type != 'append' && type != 'continue' && type != 'appendFinal' && chat.length && (lastMessage.swipe_id === undefined ||
6431+ lastMessage.is_user)) {
63696432 type = 'normal';
63706433 }
63716434
63726435 if (chat.length && (!chat[chatlastMessage.length - 1]['extra'] || typeof chat[chatlastMessage.length - 1]['extra'] !== 'object')) {
63736436 chat[chatlastMessage.length - 1]['extra'] = {};
63746437 }
63756438
63766439 // Coerce null/undefined to empty string
63776440 if (chat.length && !chat[chatlastMessage.length - 1]['extra']['.reasoning']) {
63786441 chat[chatlastMessage.length - 1]['extra']['.reasoning'] = '';
63796442 }
63806443
63816444 if (!reasoning) {
@@ -6385,70 +6448,70 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
63856448 let oldMessage = '';
63866449 const generationFinished = new Date();
63876450 if (type === 'swipe') {
63886451 oldMessage = chat[chatlastMessage.length - 1]['mes'];
63896452 chat[chatlastMessage.length - 1]['swipes'].length++;
63906453 if (chat[chatlastMessage.length - 1]['swipe_id'] === chat[chatlastMessage.length - 1]['swipes'].length - 1) {
63916454 chat[chatlastMessage.length - 1]['title'] = title;
63926455 chat[chatlastMessage.length - 1]['mes'] = getMessage;
63936456 chat[chatlastMessage.length - 1]['gen_started'] = generation_started;
63946457 chat[chatlastMessage.length - 1]['gen_finished'] = generationFinished;
63956458 chat[chatlastMessage.length - 1]['send_date'] = getMessageTimeStamp();
63966459 chat[chatlastMessage.length - 1]['extra']['.api'] = getGeneratingApi();
63976460 chat[chatlastMessage.length - 1]['extra']['.model'] = getGeneratingModel();
6398- chat[chat.length - 1]['extra']['reasoning'] = reasoning;
6461+ lastMessage.extra.reasoning = reasoning;
6399- chat[chat.length - 1]['extra']['reasoning_duration'] = null;
6462+ lastMessage.extra.reasoning_duration = null;
6400- chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
6463+ lastMessage.extra.reasoning_signature = reasoningSignature;
64016464 await processImageAttachment(chat[chat.length - 1]lastMessage, { imageUrls });
64026465 if (power_user.message_token_count_enabled) {
64036466 const tokenCountText = (reasoning || '') + chat[chatlastMessage.length - 1]['mes'];
64046467 chat[chatlastMessage.length - 1]['extra']['.token_count'] = await getTokenCountAsync(tokenCountText, 0);
64056468 }
64066469 const chat_id = (chat.length - 1);
64076470 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
64086471 addOneMessage(chat[chat_id], { type: 'swipe' });
64096472 !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
64106473 } else {
64116474 chat[chatlastMessage.length - 1]['mes'] = getMessage;
64126475 }
64136476 } else if (type === 'append' || type === 'continue') {
64146477 console.debug('Trying to append.');
64156478 oldMessage = chat[chatlastMessage.length - 1]['mes'];
64166479 chat[chatlastMessage.length - 1]['title'] = title;
64176480 chat[chatlastMessage.length - 1]['mes'] += getMessage;
64186481 chat[chatlastMessage.length - 1]['gen_started'] = generation_started;
64196482 chat[chatlastMessage.length - 1]['gen_finished'] = generationFinished;
64206483 chat[chatlastMessage.length - 1]['send_date'] = getMessageTimeStamp();
64216484 chat[chatlastMessage.length - 1]['extra']['.api'] = getGeneratingApi();
64226485 chat[chatlastMessage.length - 1]['extra']['.model'] = getGeneratingModel();
6423- chat[chat.length - 1]['extra']['reasoning'] = reasoning;
6486+ lastMessage.extra.reasoning = reasoning;
6424- chat[chat.length - 1]['extra']['reasoning_duration'] = null;
6487+ lastMessage.extra.reasoning_duration = null;
6425- chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
6488+ lastMessage.extra.reasoning_signature = reasoningSignature;
64266489 await processImageAttachment(chat[chat.length - 1]lastMessage, { imageUrls });
64276490 if (power_user.message_token_count_enabled) {
64286491 const tokenCountText = (reasoning || '') + chat[chatlastMessage.length - 1]['mes'];
64296492 chat[chatlastMessage.length - 1]['extra']['.token_count'] = await getTokenCountAsync(tokenCountText, 0);
64306493 }
64316494 const chat_id = (chat.length - 1);
64326495 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
64336496 addOneMessage(chat[chat_id], { type: 'swipe' });
64346497 !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
64356498 } else if (type === 'appendFinal') {
64366499 oldMessage = chat[chatlastMessage.length - 1]['mes'];
64376500 console.debug('Trying to appendFinal.');
64386501 chat[chatlastMessage.length - 1]['title'] = title;
64396502 chat[chatlastMessage.length - 1]['mes'] = getMessage;
64406503 chat[chatlastMessage.length - 1]['gen_started'] = generation_started;
64416504 chat[chatlastMessage.length - 1]['gen_finished'] = generationFinished;
64426505 chat[chatlastMessage.length - 1]['send_date'] = getMessageTimeStamp();
64436506 chat[chatlastMessage.length - 1]['extra']['.api'] = getGeneratingApi();
64446507 chat[chatlastMessage.length - 1]['extra']['.model'] = getGeneratingModel();
64456508 chat[chatlastMessage.length - 1]['extra']['.reasoning'] += reasoning;
6446- chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
6509+ lastMessage.extra.reasoning_signature = reasoningSignature;
64476510 await processImageAttachment(chat[chat.length - 1]lastMessage, { imageUrls });
64486511 // We don't know if the reasoning duration extended, so we don't update it here on purpose.
64496512 if (power_user.message_token_count_enabled) {
64506513 const tokenCountText = (reasoning || '') + chat[chatlastMessage.length - 1]['mes'];
64516514 chat[chatlastMessage.length - 1]['extra']['.token_count'] = await getTokenCountAsync(tokenCountText, 0);
64526515 }
64536516 const chat_id = (chat.length - 1);
64546517 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
@@ -6457,27 +6520,28 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
64576520
64586521 } else {
64596522 console.debug('entering chat update routine for non-swipe post');
64606523 chat[chat.length]const newMessage = {};
6461- chat[chat.length - 1]['extra'] = {};
6524+ chat.push(newMessage);
6462- chat[chat.length - 1]['name'] = name2;
6525+ newMessage.extra = {};
6463- chat[chat.length - 1]['is_user'] = false;
6526+ newMessage.name = name2;
6464- chat[chat.length - 1]['send_date'] = getMessageTimeStamp();
6527+ newMessage.is_user = false;
6465- chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
6528+ newMessage.send_date = getMessageTimeStamp();
6466- chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
6529+ newMessage.extra.api = getGeneratingApi();
6467- chat[chat.length - 1]['extra']['reasoning'] = reasoning;
6530+ newMessage.extra.model = getGeneratingModel();
6468- chat[chat.length - 1]['extra']['reasoning_duration'] = null;
6531+ newMessage.extra.reasoning = reasoning;
6469- chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
6532+ newMessage.extra.reasoning_duration = null;
6533+ newMessage.extra.reasoning_signature = reasoningSignature;
64706534 if (power_user.trim_spaces) {
64716535 getMessage = getMessage.trim();
64726536 }
64736537 chat[chatnewMessage.length - 1]['mes'] = getMessage;
64746538 chat[chatnewMessage.length - 1]['title'] = title;
64756539 chat[chatnewMessage.length - 1]['gen_started'] = generation_started;
64766540 chat[chatnewMessage.length - 1]['gen_finished'] = generationFinished;
64776541
64786542 if (power_user.message_token_count_enabled) {
64796543 const tokenCountText = (reasoning || '') + chat[chatnewMessage.length - 1]['mes'];
64806544 chat[chatnewMessage.length - 1]['extra']['.token_count'] = await getTokenCountAsync(tokenCountText, 0);
64816545 }
64826546
64836547 if (selected_group) {
@@ -6486,12 +6550,12 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
64866550 if (characters[this_chid].avatar != 'none') {
64876551 avatarImg = getThumbnailUrl('avatar', characters[this_chid].avatar);
64886552 }
64896553 chat[chatnewMessage.length - 1]['force_avatar'] = avatarImg;
64906554 chat[chatnewMessage.length - 1]['original_avatar'] = characters[this_chid].avatar;
6491- chat[chat.length - 1]['extra']['gen_id'] = group_generation_id;
6555+ newMessage.extra.gen_id = group_generation_id;
64926556 }
64936557
64946558 await processImageAttachment(chat[chat.length - 1]newMessage, { imageUrls });
64956559 const chat_id = (chat.length - 1);
64966560
64976561 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
@@ -6500,27 +6564,27 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
65006564 }
65016565
65026566 const item = chat[chat.length - 1];
65036567 if (item['.swipe_info'] === undefined) {
65046568 item['.swipe_info'] = [];
65056569 }
65066570 if (item['.swipe_id'] !== undefined) {
65076571 const swipeId = item['.swipe_id'];
65086572 item['.swipes'][swipeId] = item['.mes'];
65096573 item['.swipe_info'][swipeId] = {
65106574 send_date: item['.send_date'],
65116575 gen_started: item['.gen_started'],
65126576 gen_finished: item['.gen_finished'],
65136577 extra: structuredClone(item['.extra']),
65146578 };
65156579 } else {
65166580 item['.swipe_id'] = 0;
65176581 item['.swipes'] = [];
65186582 item['.swipes'][0] = chat[chatitem.length - 1]['mes'];
65196583 item['.swipe_info'][0] = {
65206584 send_date: chat[chatitem.length - 1]['send_date'],
65216585 gen_started: chat[chatitem.length - 1]['gen_started'],
65226586 gen_finished: chat[chatitem.length - 1]['gen_finished'],
65236587 extra: structuredClone(chat[chatitem.length - 1]['extra']),
65246588 };
65256589 }
65266590
@@ -6541,7 +6605,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
65416605 item.swipe_info.push(...swipeInfoArray);
65426606 }
65436607
65446608 statMesProcess(chat[chat.length - 1]item, type, characters, this_chid, oldMessage);
65456609 return { type, getMessage };
65466610}
65476611
@@ -6644,7 +6708,10 @@ export function syncMesToSwipe(messageId = null) {
66446708 return false;
66456709 }
66466710
6647- targetMessage.swipes[targetMessage.swipe_id] = targetMessage.mes;
6711+ // Only sync swipes if the chat is not pristine, so that macros in the greeting can resolve again on swipe
6712+ if (chat_metadata.tainted || chat.length > 1) {
6713+ targetMessage.swipes[targetMessage.swipe_id] = targetMessage.mes;
6714+ }
66486715
66496716 targetSwipeInfo.send_date = targetMessage.send_date;
66506717 targetSwipeInfo.gen_started = targetMessage.gen_started;
@@ -7126,7 +7193,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
71267193 return;
71277194 }
71287195
71297196 characters[this_chid]['.date_last_chat'] = Date.now();
71307197
71317198 const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
71327199 ? chat.slice(0, Number(mesId) + 1)
@@ -7343,41 +7410,49 @@ export async function unshallowCharacter(characterId) {
73437410}
73447411
73457412export async function getChat() {
7346- //console.log('/api/chats/get -- entered for -- ' + characters[this_chid].name);
73477413 try {
73487414 await unshallowCharacter(this_chid);
73497415
73507416 const response = await $.ajaxfetch('/api/chats/get', {
73517417 typemethod: 'POST',
7352- url: '/api/chats/get',
7418+ headers: getRequestHeaders(),
7353- data: JSON.stringify({
7419+ cache: 'no-cache',
7420+ body: JSON.stringify({
73547421 ch_name: characters[this_chid].name,
73557422 file_name: characters[this_chid].chat,
73567423 avatar_url: characters[this_chid].avatar,
73577424 }),
7358- dataType: 'json',
7359- contentType: 'application/json',
73607425 });
7361- if (response[0] !== undefined) {
7362- chat.splice(0, chat.length, ...response);
7363- chat_metadata = chat[0]['chat_metadata'] ?? {};
73647426
7365- chat.shift();
7427+ if (!response.ok) {
7428+ throw new Error('Chat could not be loaded');
7429+ }
7430+
7431+ const data = await response.json();
7432+ if (Array.isArray(data) && data.length > 0) {
7433+ /** @type {ChatHeader} */
7434+ const chatHeader = data.shift();
7435+ chat_metadata = chatHeader?.chat_metadata ?? {};
7436+ chat.splice(0, chat.length, ...data);
73667437 chat.forEach(ensureMessageMediaIsArray);
7438+ } else {
7439+ // An empty/corrupted chat file
7440+ chat.splice(0, chat.length);
7441+ chat_metadata = {};
73677442 }
73687443 if (!chat_metadata['.integrity']) {
73697444 chat_metadata['.integrity'] = uuidv4();
73707445 }
73717446 await getChatResult();
73727447 eventSource.emit('chatLoaded'event_types.CHAT_LOADED, { detail: { id: this_chid, character: characters[this_chid] } });
73737448
73747449 // Focus on the textarea if not already focused on a visible text input
7375- setTimeout(function () {
7450+ delay(debounce_timeout.short).then(() => {
73767451 if ($(document.activeElement).is('input:visible, textarea:visible')) {
73777452 return;
73787453 }
73797454 $('#send_textarea').trigger('click').trigger('focus');
73807455 }, 200);
73817456 } catch (error) {
73827457 await getChatResult();
73837458 console.log(error);
@@ -7431,9 +7506,9 @@ function getFirstMessage() {
74317506 message.mes = swipes[0];
74327507 }
74337508
74347509 message['.swipe_id'] = 0;
74357510 message['.swipes'] = swipes;
74367511 message['.swipe_info'] = swipes.map(_ => ({
74377512 send_date: message.send_date,
74387513 gen_started: void 0,
74397514 gen_finished: void 0,
@@ -7446,9 +7521,8 @@ function getFirstMessage() {
74467521
74477522export async function openCharacterChat(file_name) {
74487523 await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
74497524 await clearChat({ clearData: true });
74507525 characters[this_chid]['.chat'] = file_name;
7451- chat.length = 0;
74527526 chat_metadata = {};
74537527 await getChat();
74547528 $('#selected_chat_pole').val(file_name);
@@ -7717,6 +7791,10 @@ export async function getSettings() {
77177791
77187792 selected_button = settings.selected_button;
77197793
7794+ // TODO: Move me into firstLoadInit when experimental toggle is removed
7795+ // power_user.experimental_macro_engine
7796+ initMacros();
7797+
77207798 if (data.enable_extensions) {
77217799 const enableAutoUpdate = Boolean(data.enable_extensions_auto_update);
77227800 const isVersionChanged = settings.currentVersion !== currentVersion;
@@ -7833,7 +7911,7 @@ function updateMessage(div) {
78337911 const mes = chat[mesElement.attr('mesid')];
78347912
78357913 // editing old messages
78367914 mes['.extra'] ??= {};
78377915
78387916 let regexPlacement;
78397917 if (mes?.is_user) {
@@ -7864,10 +7942,10 @@ function updateMessage(div) {
78647942 if (bias) {
78657943 text = removeMacros(text);
78667944 }
78677945 mes['.mes'] = text;
78687946 if (mes['.swipe_id'] !== undefined) {
78697947 ensureSwipes(mes);
78707948 mes['.swipes'][mes['.swipe_id']] = text;
78717949 }
78727950
78737951 if (mes?.is_system || mes?.is_user || mes.extra?.type === system_message_types.NARRATOR) {
@@ -7876,7 +7954,7 @@ function updateMessage(div) {
78767954 mes.extra.bias = null;
78777955 }
78787956
78797957 chat_metadata['.tainted'] = true;
78807958
78817959 return { mesBlock, text, mes, bias };
78827960}
@@ -7960,6 +8038,7 @@ export async function messageEdit(editMessageId) {
79608038 const editTextArea = document.createElement('textarea');
79618039 editTextArea.id = 'curEditTextarea';
79628040 editTextArea.className = 'edit_textarea mdHotkeys';
8041+ editTextArea.dataset.macros = '';
79638042 messageText.append(editTextArea);
79648043
79658044 const text = trimSpaces(editMessage.mes || '');
@@ -7990,7 +8069,7 @@ export async function messageEdit(editMessageId) {
79908069 * @param {number} [messageId=this_edit_mes_id]
79918070 */
79928071async function messageEditCancel(messageId = this_edit_mes_id) {
79938072 let text = chat[messageId]['.mes'];
79948073 let thisMesDiv;
79958074 // If this is the button then select it's parent. Otherwise, select by messageId.
79968075 if (this?.classList?.contains('mes_edit_cancel')) {
@@ -8076,6 +8155,7 @@ async function messageEditMove(sourceId, targetId) {
80768155 this_edit_mes_id = targetId;
80778156 }
80788157
8158+ swapItemizedPrompts(sourceId, targetId);
80798159 updateViewMessageIds();
80808160 refreshSwipeButtons();
80818161 await saveChatConditional();
@@ -8089,9 +8169,6 @@ async function messageEditDone(div) {
80898169 }
80908170
80918171 let { mesBlock, text, mes, bias } = updateMessage(div);
8092- if (this_edit_mes_id == 0) {
8093- text = substituteParams(text);
8094- }
80958172
80968173 await eventSource.emit(event_types.MESSAGE_EDITED, this_edit_mes_id);
80978174 text = chat[this_edit_mes_id]?.mes ?? text;
@@ -8138,7 +8215,7 @@ async function messageEditDone(div) {
81388215export async function getChatsFromFiles(data, isGroupChat) {
81398216 const context = getContext();
81408217 let chat_dict = {};
81418218 let chat_list = Object.values(data).sort((a, b) => a['.file_name'].localeCompare(b['.file_name'])).reverse();
81428219
81438220 let chat_promise = chat_list.map(({ file_name }) => {
81448221 return new Promise(async (res, rej) => {
@@ -8215,7 +8292,7 @@ export async function getPastCharacterChats(characterId = null) {
82158292 }
82168293
82178294 const chats = Object.values(data);
82188295 return chats.sort((a, b) => a['.file_name'].localeCompare(b['.file_name'])).reverse();
82198296}
82208297
82218298/**
@@ -8227,9 +8304,9 @@ export function getCurrentChatDetails() {
82278304 }
82288305
82298306 const group = selected_group ? groups.find(x => x.id === selected_group) : null;
82308307 const currentChat = selected_group ? group?.chat_id : characters[this_chid]['.chat'];
82318308 const displayName = selected_group ? group?.name : characters[this_chid].name;
82328309 const avatarImg = selected_group ? group?.avatar_url : getThumbnailUrl('avatar', characters[this_chid]['.avatar']);
82338310 return { sessionName: currentChat, group: group, characterName: displayName, avatarImgURL: avatarImg };
82348311}
82358312
@@ -8272,8 +8349,6 @@ export async function displayPastChats(hightlightNames = []) {
82728349
82738350async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
82748351 try {
8275- const trimExtension = (fileName) => String(fileName).replace('.jsonl', '');
8276-
82778352 const response = await fetch('/api/chats/search', {
82788353 method: 'POST',
82798354 headers: getRequestHeaders(),
@@ -8294,7 +8369,7 @@ async function displayChats(searchQuery, currentChat, displayName, avatarImg, se
82948369 filteredData.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
82958370
82968371 for (const chat of filteredData) {
82978372 const isSelected = trimExtension(currentChat) === trimExtension(chat.file_name);
82988373 const template = $('#past_chat_template .select_chat_block_wrapper').clone();
82998374 template.find('.select_chat_block').attr('file_name', chat.file_name);
83008375 template.find('.avatar img').attr('src', avatarImg);
@@ -8693,9 +8768,9 @@ export async function setCharacterSettingsOverrides() {
86938768 return;
86948769 }
86958770
86968771 const scenarioOverrideValue = chat_metadata['.scenario'] || '';
86978772 const exampleMessagesValue = chat_metadata['.mes_example'] || '';
86988773 const systemPromptValue = chat_metadata['.system_prompt'] || '';
86998774 const isGroup = !!selected_group;
87008775
87018776 const $template = $(await renderTemplateAsync('scenarioOverride'));
@@ -8742,9 +8817,9 @@ export async function setCharacterSettingsOverrides() {
87428817 allowVerticalScrolling: true,
87438818 });
87448819
87458820 chat_metadata['.scenario'] = pendingChanges.scenario;
87468821 chat_metadata['.mes_example'] = pendingChanges.examples;
87478822 chat_metadata['.system_prompt'] = pendingChanges.system_prompt;
87488823 await saveMetadata();
87498824}
87508825
@@ -9042,7 +9117,7 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
90429117 // Select the next swipe, or the one before if it was the last one
90439118 const newSwipeId = Math.min(swipeId, message.swipes.length - 1);
90449119
90459120 chat_metadata['.tainted'] = true;
90469121
90479122 messageId = Number(messageId);
90489123 swipeId = Number(swipeId);
@@ -9399,6 +9474,11 @@ function addAlternateGreeting(template, greeting, index, getArray, popup) {
93999474 * @param {Event} [e] Event that triggered the function call.
94009475 */
94019476export async function createOrEditCharacter(e) {
9477+ if (!settingsReady) {
9478+ console.warn('Settings not ready, aborting character creation/editing.');
9479+ return;
9480+ }
9481+
94029482 $('#rm_info_avatar').html('');
94039483 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
94049484 formData.set('fav', String(fav_ch_checked));
@@ -9556,7 +9636,7 @@ export async function createOrEditCharacter(e) {
95569636 !isNewChat &&
95579637 message.mes &&
95589638 !selected_group &&
95599639 !chat_metadata['.tainted'] &&
95609640 (chat.length === 0 || (chat.length === 1 && !chat[0].is_user && !chat[0].is_system));
95619641
95629642 if (shouldRegenerateMessage) {
@@ -9576,23 +9656,6 @@ export async function createOrEditCharacter(e) {
95769656}
95779657
95789658/**
9579- * Visually updates all chat messages including andd after index by removing them, then adding them.
9580- * @param {ChatMessage[]} chat All messages in chat before index will remain unchanged.
9581- * @param {Number} index The last unchanged messageId.
9582- */
9583-export async function redisplayChat(chat, index) {
9584- //Remove messages after index.
9585- chatElement.children(`.mes[mesid="${index}"]`).nextAll('.mes').addBack().remove();
9586-
9587- //Skip to index, then add extra messages.
9588- for (let i = index; i <= chat.length - 1; i++) {
9589- //addOneMessage will update last_mes.
9590- addOneMessage(chat[i], { scroll: false, showSwipes: false, forceId: i });
9591- }
9592- refreshSwipeButtons();
9593-}
9594-
9595-/**
95969659 * Formats a counter for a swipe view.
95979660 * @param {number} current The current number of items.
95989661 * @param {number} total The total number of items.
@@ -9669,7 +9732,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
96699732 console.error(`Message #${mesId}'s DOM element is not valid.`);
96709733 return;
96719734 }
96729735 const originalSwipeId = Number(chat[mesId]?.['swipe_id'] ?? 0);
96739736 let newSwipeId = Number(forceSwipeId ?? originalSwipeId);
96749737
96759738 /**
@@ -9716,7 +9779,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
97169779 }
97179780
97189781 //Clamp Id between swipes.
97199782 let clampedId = clamp(chat[mesId]['.swipe_id'], 0, Math.max(0, chat[mesId]['.swipes'].length - 1));
97209783
97219784 await updateSwipeCounter(mesId);
97229785 //Fallback.
@@ -9746,7 +9809,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
97469809
97479810 //Update the chat.
97489811 await loadFromSwipeId(mesId, chat[mesId].swipe_id);
97499812 await redisplayChat(chat,{ startIndex: mesId });
97509813 }
97519814 else {
97529815 await Popup.show.confirm(
@@ -9808,7 +9871,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
98089871 */
98099872 async function loadFromSwipeId(mesId, newSwipeId) {
98109873 //Update the swipe_id.
98119874 chat[mesId]['.swipe_id'] = newSwipeId;
98129875
98139876 clearMessageData(chat[mesId]);
98149877
@@ -9880,7 +9943,8 @@ export async function swipe(event, direction, { source, repeated, message = chat
98809943 return true;
98819944 };
98829945 //Wait for the animation's end. https://developer.mozilla.org/en-US/docs/Web/API/Animation/finished
98839946 const animationanimations = swipedElementsDiv[0]?.getAnimations().filter((a) => a['animationName'] ==?? 'slide')[0];
9947+ const animation = animations.filter((a) => a instanceof globalThis.CSSAnimation && a.animationName == 'slide')[0];
98849948 try {
98859949 await Promise.race([animation?.finished, createTimeout(duration * 2, `The ${duration}ms swipe animation has not ended after ${duration * 2}ms. It has been skipped.`)].filter(Boolean));
98869950 } catch (error) {
@@ -9968,7 +10032,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
996810032
996910033 const tokenCountText = (chat[mesId]?.extra?.reasoning || '') + chat[mesId].mes;
997010034 const tokenCount = await getTokenCountAsync(tokenCountText, 0);
997110035 chat[mesId]['.extra']['.token_count'] = tokenCount;
997210036 thisMesDiv.find('.tokenCounterDisplay').text(`${tokenCount}t`);
997310037 }
997410038 }
@@ -9977,7 +10041,9 @@ export async function swipe(event, direction, { source, repeated, message = chat
997710041 thisMesDiv.css('height', thisMesDivHeight);
997810042 expandNewMessage(thisMesDiv);
997910043
9980- appendMediaToMessage(chat[mesId], thisMesDiv);
10044+ if (run_generate) {
10045+ appendMediaToMessage(chat[mesId], thisMesDiv);
10046+ }
998110047
998210048 await eventSource.emit(event_types.MESSAGE_SWIPED, (mesId));
998310049
@@ -10007,20 +10073,20 @@ export async function swipe(event, direction, { source, repeated, message = chat
1000710073 // Make sure ad-hoc changes to extras are saved before swiping away
1000810074 syncMesToSwipe(mesId);
1000910075
1001010076 if (chat[mesId]['.swipe_id'] === undefined) { // if there is no swipe-message in the last spot of the chat array
1001110077 chat[mesId]['.swipe_id'] = 0; // set it to id 0
1001210078 chat[mesId]['.swipes'] = []; // empty the array
1001310079 chat[mesId]['.swipe_info'] = [];
1001410080 chat[mesId]['.swipes'][0] = chat[mesId]['.mes']; //assign swipe array with last chat[mesId] from chat
1001510081 chat[mesId]['.swipe_info'][0] = {
1001610082 'send_date': chat[mesId]['.send_date'],
1001710083 'gen_started': chat[mesId]['.gen_started'],
1001810084 'gen_finished': chat[mesId]['.gen_finished'],
1001910085 'extra': structuredClone(chat[mesId]['.extra']),
1002010086 };
1002110087 }
1002210088 // If the user is holding down the key and we're at the last or first swipe, don't do anything.
1002310089 let isLastSwipe = (direction === SWIPE_DIRECTION.RIGHT) ? (chat[mesId].swipe_id === Math.max(0, chat[mesId]['.swipes'].length - 1)) : chat[mesId].swipe_id === 0;
1002410090 if (source === SWIPE_SOURCE.KEYBOARD && repeated && isLastSwipe) {
1002510091 await endSwipe();
1002610092 return;
@@ -10036,12 +10102,12 @@ export async function swipe(event, direction, { source, repeated, message = chat
1003610102 if (forceSwipeId == null) newSwipeId--;
1003710103 //Loop to last swipe if negative.
1003810104 if (newSwipeId < 0) {
1003910105 newSwipeId = Math.max(0, chat[mesId]['.swipes'].length - 1);
1004010106 }
1004110107 //Limit swipe_id to swipes.
1004210108 if (newSwipeId > chat[mesId]['.swipes'].length - 1) {
1004310109 toastr.warning(`The swipe_id for message #${mesId} was ${newSwipeId}. It has been reset to ${chat[mesId]['.swipes'].length - 1}.`);
1004410110 chat[mesId]['.swipe_id'] = chat[mesId]['.swipes'].length - 1;
1004510111 await endSwipe();
1004610112 return;
1004710113 }
@@ -10056,24 +10122,24 @@ export async function swipe(event, direction, { source, repeated, message = chat
1005610122 //Minimum of zero.
1005710123 if (newSwipeId < 0) {
1005810124 toastr.warning(`The swipe_id for message #${mesId} was ${newSwipeId}. It has been reset to zero.`);
1005910125 chat[mesId]['.swipe_id'] = 0;
1006010126 await endSwipe();
1006110127 return;
1006210128 }
1006310129
1006410130 //If overswiping.
1006510131 if (newSwipeId >= chat[mesId]['.swipes'].length) {
1006610132 newSwipeId = chat[mesId]['.swipes'].length;
1006710133
1006810134 //Update the swipe_id.
1006910135 chat[mesId]['.swipe_id'] = newSwipeId;
1007010136
1007110137 const overswipe = getOverswipeBehavior(mesId);
1007210138
1007310139 //Cancel the generation.
1007410140 if (overswipe == OVERSWIPE_BEHAVIOR.NONE) {
1007510141 //Cancel swipe.
1007610142 chat[mesId]['.swipe_id'] = originalSwipeId;
1007710143 await endSwipe();
1007810144 return;
1007910145 }
@@ -10294,8 +10360,7 @@ export async function doNewChat({ deleteCurrentChat = false } = {}) {
1029410360
1029510361 //Fix it; New chat doesn't create while open create character menu
1029610362 await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
1029710363 await clearChat({ clearData: true });
10298- chat.length = 0;
1029910364
1030010365 chat_file_for_del = getCurrentChatDetails()?.sessionName;
1030110366
@@ -10414,8 +10479,7 @@ export async function renameChat(oldFileName, newName) {
1041410479export async function closeCurrentChat() {
1041510480 if (is_send_press == false) {
1041610481 await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
1041710482 await clearChat({ clearData: true });
10418- chat.length = 0;
1041910483 resetSelectedGroup();
1042010484 setCharacterId(undefined);
1042110485 setCharacterName('');
@@ -10946,7 +11010,7 @@ jQuery(async function () {
1094611010 if (group) {
1094711011 await deleteGroupChat(group, chatFile);
1094811012 } else {
1094911013 await delChat(`${chatFile}.jsonl`);
1095011014 }
1095111015
1095211016 if (fromSlashCommand) { // When called from `/delchat` command, don't re-open the history view.
@@ -10963,18 +11027,18 @@ jQuery(async function () {
1096311027
1096411028 $(document).on('click', '.PastChat_cross', async function (e, { fromSlashCommand = false } = {}) {
1096511029 e.stopPropagation();
1096611030 chat_file_for_delconst deleteFileName = $(this).attr('file_name');
1096711031 console.debug('detected cross click for' + chat_file_for_deldeleteFileName);
1096811032
1096911033 // Skip confirmation if called from a slash command.
1097011034 if (fromSlashCommand) {
1097111035 await handleDeleteChat(chat_file_for_deldeleteFileName, selected_group, true);
1097211036 return;
1097311037 }
1097411038
1097511039 const result = await callGenericPopup('<h3>' + t`Delete the Chat File?` + '</h3>', POPUP_TYPE.CONFIRM);
1097611040 if (result === POPUP_RESULT.AFFIRMATIVE) {
1097711041 await handleDeleteChat(chat_file_for_deldeleteFileName, selected_group, false);
1097811042 }
1097911043 });
1098011044
@@ -11008,8 +11072,7 @@ jQuery(async function () {
1100811072 $('#character_popup').css('display', 'none');
1100911073 });
1101011074
1101111075 $('#dialogue_popup_ok').on('click', async function (_e, customData) {
11012- const fromSlashCommand = customData?.fromSlashCommand || false;
1101311076 dialogueCloseStop = false;
1101411077 $('#shadow_popup').transition({
1101511078 opacity: 0,
@@ -11023,10 +11086,6 @@ jQuery(async function () {
1102311086 $('#dialogue_popup').removeClass('wide_dialogue_popup');
1102411087 }, animation_duration);
1102511088
11026- if (popup_type == 'del_chat') {
11027- await handleDeleteChat(chat_file_for_del, selected_group, fromSlashCommand);
11028- }
11029-
1103011089 if (dialogueResolve) {
1103111090 if (popup_type == 'input') {
1103211091 dialogueResolve($('#dialogue_popup_input').val());
@@ -11139,8 +11198,7 @@ jQuery(async function () {
1113911198
1114011199 $(document).on('click', '.renameChatButton', async function (e) {
1114111200 e.stopPropagation();
1114211201 const oldFileNameFulloldFileName = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();
11143- const oldFileName = oldFileNameFull.replace('.jsonl', '');
1114411202
1114511203 const popupText = await renderTemplateAsync('chatRename');
1114611204 const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, oldFileName);
@@ -11161,10 +11219,9 @@ jQuery(async function () {
1116111219 e.stopPropagation();
1116211220 const format = $(this).data('format') || 'txt';
1116311221 await saveChatConditional();
1116411222 const filenamefullfilename = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();
1116511223 console.log(`exporting ${filenamefullfilename} in ${format} format`);
1116611224
11167- const filename = filenamefull.replace('.jsonl', '');
1116811225 const body = {
1116911226 is_group: !!selected_group,
1117011227 avatar_url: characters[this_chid]?.avatar,
@@ -11398,10 +11455,13 @@ jQuery(async function () {
1139811455 });
1139911456
1140011457 if (this_del_mes >= 0) {
11458+ for (let i = (chat.length - 1); i >= this_del_mes; i--) {
11459+ deleteItemizedPromptForMessage(i);
11460+ }
1140111461 chatElement.find(`.mes[mesid="${this_del_mes}"]`).nextAll('div').remove();
1140211462 chatElement.find(`.mes[mesid="${this_del_mes}"]`).remove();
1140311463 chat.length = this_del_mes;
1140411464 chat_metadata['.tainted'] = true;
1140511465 await saveChatConditional();
1140611466 chatElement.scrollTop(chatElement[0].scrollHeight);
1140711467 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
@@ -11488,7 +11548,7 @@ jQuery(async function () {
1148811548 if (this_chid !== undefined || selected_group || name2 === neutralCharacterName) {
1148911549 try {
1149011550 const messageId = $(this).closest('.mes').attr('mesid');
1149111551 const text = chat[messageId]['.mes'];
1149211552 await copyText(text);
1149311553 toastr.info('Copied!', '', { timeOut: 2000 });
1149411554 } catch (err) {
@@ -11515,8 +11575,8 @@ jQuery(async function () {
1151511575 let mes_edited = chatElement.find(`[mesid="${this_edit_mes_id}"]`).find('.mes_edit_done');
1151611576 if (Number(edit_mes_id) == chat.length - 1) { //if the generating swipe (...)
1151711577 let run_edit = true;
1151811578 if (chat[edit_mes_id]['.swipe_id'] !== undefined) {
1151911579 if (chat[edit_mes_id]['.swipes'].length === chat[edit_mes_id]['.swipe_id']) {
1152011580 run_edit = false;
1152111581 }
1152211582 }
@@ -11637,14 +11697,16 @@ jQuery(async function () {
1163711697 const oldScroll = chatElement[0].scrollTop;
1163811698 const clone = structuredClone(chat[this_edit_mes_id]);
1163911699 clone.send_date = Date.now();
1164011700 clone.mesconst this_edit_mes_element = $(this).closest('.mes').find('.edit_textarea').val().toString();
11701+ clone.mes = this_edit_mes_element.find('.edit_textarea').val().toString();
1164111702
1164211703 if (power_user.trim_spaces) {
1164311704 clone.mes = clone.mes.trim();
1164411705 }
1164511706
1164611707 chat.splice(Number(this_edit_mes_id) + 1, 0, clone);
11647- addOneMessage(clone, { insertAfter: this_edit_mes_id });
11708+ const newMessageElement = updateMessageElement(clone);
11709+ this_edit_mes_element.after(newMessageElement);
1164811710
1164911711 updateViewMessageIds();
1165011712 await saveChatConditional();
@@ -11655,8 +11717,8 @@ jQuery(async function () {
1165511717 $(document).on('click', '.mes_edit_delete', async function (event, customData) {
1165611718 const fromSlashCommand = customData?.fromSlashCommand || false;
1165711719 const message = chat[this_edit_mes_id];
1165811720 const selectedSwipe = message['.swipe_id'] ?? undefined;
1165911721 const swipesArray = Array.isArray(message['.swipes']) ? message['.swipes'] : [];
1166011722 const canDeleteSwipe = power_user.confirm_message_delete && !fromSlashCommand && !message.is_user && swipesArray.length > 1 && this_edit_mes_id === chat.length - 1 && selectedSwipe !== undefined;
1166111723 await deleteMessage(Number(this_edit_mes_id), canDeleteSwipe ? selectedSwipe : undefined, power_user.confirm_message_delete && fromSlashCommand !== true);
1166211724 });
@@ -12012,7 +12074,9 @@ jQuery(async function () {
1201212074 }
1201312075 if (this_edit_mes_id === undefined && $('#mes_stop').is(':visible')) {
1201412076 $('#mes_stop').trigger('click');
12015- if (chat.length && Array.isArray(chat[chat.length - 1].swipes) && chat[chat.length - 1].swipe_id == chat[chat.length - 1].swipes.length) {
12077+ if (chat.length === 0) return;
12078+ const lastMessage = chat[chat.length - 1];
12079+ if (Array.isArray(lastMessage.swipes) && lastMessage.swipe_id == lastMessage.swipes.length) {
1201612080 $('.last_mes .swipe_left').trigger('click');
1201712081 }
1201812082 }
@@ -12071,7 +12135,7 @@ jQuery(async function () {
1207112135 });
1207212136
1207312137 // Remember the chat currently selected, so we can reload it after the replacement
1207412138 const currentChatFile = characters[this_chid]['.chat'];
1207512139 async function postReplace() {
1207612140 await openCharacterChat(currentChatFile);
1207712141 }
public/scripts/PromptManager.js+1 -1
@@ -765,7 +765,7 @@ class PromptManager {
765765 eventSource.on(event_types.CHATCOMPLETION_MODEL_CHANGED, () => this.renderDebounced());
766766
767767 // Re-render when the character changes.
768768 eventSource.on('chatLoaded'event_types.CHAT_LOADED, (event) => {
769769 this.handleCharacterSelected(event);
770770 this.saveServiceSettings().then(() => this.renderDebounced());
771771 });
public/scripts/RossAscends-mods.js+5 -5
@@ -408,7 +408,7 @@ function RA_autoconnect(PrevApi) {
408408 || (secret_state[SECRET_KEYS.FIREWORKS] && oai_settings.chat_completion_source == chat_completion_sources.FIREWORKS)
409409 || (secret_state[SECRET_KEYS.COMETAPI] && oai_settings.chat_completion_source == chat_completion_sources.COMETAPI)
410410 || (secret_state[SECRET_KEYS.ZAI] && oai_settings.chat_completion_source == chat_completion_sources.ZAI)
411411 || (secret_state[SECRET_KEYS.POLLINATIONS] && oai_settings.chat_completion_source === chat_completion_sources.POLLINATIONS)
412412 || (isValidUrl(oai_settings.custom_url) && oai_settings.chat_completion_source == chat_completion_sources.CUSTOM)
413413 || (secret_state[SECRET_KEYS.AZURE_OPENAI] && oai_settings.chat_completion_source == chat_completion_sources.AZURE_OPENAI)
414414 ) {
@@ -996,7 +996,7 @@ export function initRossMods() {
996996 }
997997
998998 //Enter to send when send_textarea in focus
999999 if (document.activeElement == hotkeyTargets['.send_textarea']) {
10001000 const sendOnEnter = shouldSendOnEnter();
10011001 if (!event.isComposing && !event.shiftKey && !event.ctrlKey && !event.altKey && event.key == 'Enter' && sendOnEnter) {
10021002 event.preventDefault();
@@ -1004,7 +1004,7 @@ export function initRossMods() {
10041004 return;
10051005 }
10061006 }
10071007 if (document.activeElement == hotkeyTargets['.dialogue_popup_input'] && !isMobile()) {
10081008 if (!event.shiftKey && !event.ctrlKey && event.key == 'Enter') {
10091009 event.preventDefault();
10101010 $('#dialogue_popup_ok').trigger('click');
@@ -1139,7 +1139,7 @@ export function initRossMods() {
11391139
11401140 if (event.ctrlKey && event.key == 'ArrowUp') { //edits last USER message if chatbar is empty and focused
11411141 if (
11421142 hotkeyTargets['.send_textarea'].value === '' &&
11431143 chatbarInFocus === true &&
11441144 ($('.swipe_right:last').css('display') === 'flex' || $('.last_mes').attr('is_system') === 'true') &&
11451145 $('#character_popup').css('display') === 'none' &&
@@ -1158,7 +1158,7 @@ export function initRossMods() {
11581158 if (event.key == 'ArrowUp') { //edits last message if chatbar is empty and focused
11591159 console.log('got uparrow input');
11601160 if (
11611161 hotkeyTargets['.send_textarea'].value === '' &&
11621162 chatbarInFocus === true &&
11631163 //$('.swipe_right:last').css('display') === 'flex' &&
11641164 $('.last_mes .mes_buttons').is(':visible') &&
public/scripts/authors-note.js+1 -1
@@ -594,7 +594,7 @@ function registerAuthorsNoteMacros() {
594594 handler: () => chat_metadata[metadata_keys.prompt] ?? '',
595595 });
596596 macros.register('charAuthorsNote', {
597597 category: MacroCategory.CHARACTERPROMPTS,
598598 description: t`The contents of the Character Author's Note`,
599599 handler: () => this_chid !== undefined ? (extension_settings.note.chara.find((e) => e.name === getCharaFilename())?.prompt ?? '') : '',
600600 });
public/scripts/autocomplete/AutoComplete.js+60 -8
@@ -155,6 +155,10 @@ export class AutoComplete {
155155 */
156156 updateName(item) {
157157 const chars = Array.from(item.dom.querySelector('.name').children);
158+ if (item.forceFullNameMatch) {
159+ chars.forEach(c => c.classList.toggle('matched', true));
160+ return;
161+ }
158162 switch (this.matchType) {
159163 case 'strict': {
160164 chars.forEach((it, idx) => {
@@ -275,6 +279,7 @@ export class AutoComplete {
275279 //TODO check if isInput and isForced are both required
276280 this.text = this.textarea.value;
277281 this.isReplaceable = false;
282+ this.isShowForced = isForced; // Store forced state for checkIfActivate to access
278283
279284 if (document.activeElement != this.textarea) {
280285 // only show with textarea in focus
@@ -311,8 +316,8 @@ export class AutoComplete {
311316 this.name = this.parserResult.name.toLowerCase() ?? '';
312317
313318 const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0);
314319 if (isForced || isInput || isSelect) {
315320 // if forced (ctrl+space) or user input or just selected an option...
316321 if (isCursorInNamePart) {
317322 // ...and cursor is somewhere in the name part (including right behind the final char)
318323 // -> show autocomplete for the (partial if cursor in the middle) name
@@ -393,8 +398,20 @@ export class AutoComplete {
393398 this.updateName(option);
394399 return option;
395400 })
396401 // sort by priority first, then by fuzzy score or alphabetical
397- .toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name));
402+ .toSorted((a, b) => {
403+ // First compare by sortPriority (lower = higher priority)
404+ const priorityA = a.sortPriority ?? 100;
405+ const priorityB = b.sortPriority ?? 100;
406+ if (priorityA !== priorityB) {
407+ return priorityA - priorityB;
408+ }
409+ // Then by fuzzy score or alphabetical
410+ if (this.matchType == 'fuzzy') {
411+ return this.fuzzyScoreCompare(a, b);
412+ }
413+ return a.name.localeCompare(b.name);
414+ });
398415
399416
400417
@@ -430,7 +447,7 @@ export class AutoComplete {
430447 } else if (!this.isReplaceable && this.result.length > 1) {
431448 return this.hide();
432449 }
433450 this.selectedItem = this.selectDefaultItem(this.result[0]);
434451 this.isActive = true;
435452 this.wasForced = isForced;
436453 this.renderDebounced();
@@ -588,7 +605,22 @@ export class AutoComplete {
588605 if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
589606 return this.hide();
590607 }
591608 constlet left = Math.max(rect.left, location.left) - layerRect.left;
609+
610+ // Check if the autocomplete list is constrained by the right edge of the viewport.
611+ // If so, adjust the details panel position to align with the actual list position.
612+ // Only do this when the list is actually visible (isReplaceable).
613+ if (this.isReplaceable) {
614+ const listRect = this.dom.getBoundingClientRect();
615+ const listActualLeft = listRect.left - layerRect.left;
616+ const isConstrainedRight = listActualLeft < left - 5; // 5px tolerance
617+
618+ if (isConstrainedRight) {
619+ // Use the actual list position instead of cursor position
620+ left = listActualLeft;
621+ }
622+ }
623+
592624 this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
593625 if (this.isReplaceable) {
594626 this.detailsWrap.classList.remove('full');
@@ -680,8 +712,10 @@ export class AutoComplete {
680712 */
681713 async select() {
682714 if (this.isReplaceable && this.selectedItem.value !== null) {
683- this.textarea.value = `${this.text.slice(0, this.effectiveParserResult.start)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
715+ // Apply per-option replacement offset (e.g., for closing tags that need to replace leading whitespace)
684716 this.textarea.selectionStartconst effectiveStart = this.effectiveParserResult.start + (this.selectedItem.replacer.lengthreplacementStartOffset ?? 0);
717+ this.textarea.value = `${this.text.slice(0, effectiveStart)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
718+ this.textarea.selectionStart = effectiveStart + this.selectedItem.replacer.length;
685719 this.textarea.selectionEnd = this.textarea.selectionStart;
686720 this.show(false, false, true);
687721 } else {
@@ -697,6 +731,24 @@ export class AutoComplete {
697731
698732
699733 /**
734+ * Select the default item for the autocomplete list.
735+ * Selects the first selectable item if any is present, or falls back to the last item.
736+ * (To preserve context of where we are with multiple non-selectable options, if they are present for info)
737+ * @param {AutoCompleteOption[]} result The list of autocomplete options.
738+ * @returns {AutoCompleteOption} The item to select.
739+ */
740+ selectDefaultItem(result) {
741+ if (result.length === 0) return null;
742+
743+ // Find first selectable item
744+ const firstSelectable = result.find(it => it.isSelectable);
745+ if (firstSelectable) return firstSelectable;
746+
747+ // Fall back to last item
748+ return result[result.length - 1];
749+ }
750+
751+ /**
700752 * Mark the item at newIdx in the autocomplete list as selected.
701753 * @param {number} newIdx
702754 */
public/scripts/autocomplete/AutoCompleteNameResultBase.js+2 -2
@@ -24,7 +24,7 @@ export class AutoCompleteNameResultBase {
2424 this.start = start;
2525 this.optionList = optionList;
2626 this.canBeQuoted = canBeQuoted;
2727 this.noMatchText =if (makeNoMatchText ??) this.makeNoMatchText = makeNoMatchText;
2828 this.noOptionstext =if (makeNoOptionsText ??) this.makeNoOptionsText = makeNoOptionsText;
2929 }
3030}
public/scripts/autocomplete/AutoCompleteOption.js+16 -0
@@ -13,6 +13,22 @@ export class AutoCompleteOption {
1313 /** @type {(input:string)=>boolean} */ matchProvider;
1414 /** @type {(input:string)=>string} */ valueProvider;
1515 /** @type {boolean} */ makeSelectable = false;
16+ /** @type {boolean} */ forceFullNameMatch = false;
17+
18+ /**
19+ * Offset to adjust the replacement start position.
20+ * Negative values start replacement earlier (e.g., -2 to include 2 chars before normal start).
21+ * Used by closing tag autocomplete to replace leading whitespace.
22+ * @type {number}
23+ */
24+ replacementStartOffset = 0;
25+
26+ /**
27+ * Priority for sorting. Lower values = higher priority (sorted first).
28+ * Default is 100 (normal priority). Use lower values for items that should appear at the top.
29+ * @type {number}
30+ */
31+ sortPriority = 100;
1632
1733
1834 /**
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+1644 -32
@@ -9,8 +9,11 @@ import {
99 createSourceIndicator,
1010 createAliasIndicator,
1111 renderMacroDetails,
1212} from '../macros/engine/MacroBrowser.js';
1313import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
14+import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
15+import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js';
16+import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
1417
1518/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
1619
@@ -19,9 +22,46 @@ import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js'
1922 * @typedef {Object} MacroAutoCompleteContext
2023 * @property {string} fullText - The full macro text being typed (without {{ }}).
2124 * @property {number} cursorOffset - Cursor position within the macro text.
25+ * @property {string} paddingBefore - Padding before the macro identifier/flags.
2226 * @property {string} identifier - The macro identifier (name).
27+ * @property {number} identifierStart - Start position of the identifier within the macro text.
28+ * @property {string[]} flags - Array of flag symbols typed (e.g., ['!', '?']).
29+ * @property {string|null} currentFlag - The flag symbol cursor is currently on (last typed flag), or null.
30+ * @property {boolean} isInFlagsArea - Whether cursor is in the flags area (before identifier starts).
2331 * @property {string[]} args - Array of arguments typed so far.
2432 * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
33+ * @property {boolean} isTypingSeparator - Whether cursor is on a partial separator (single ':').
34+ * @property {boolean} isTypingClosingBrace - Whether cursor is typing the first closing brace on a standalone macro.
35+ * @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args).
36+ * @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).
37+ * @property {number} separatorCount - Number of '::' separators found.
38+ * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
39+ * @property {boolean} [isScopedContentOptional] - Whether the scoped content is optional (for display purposes).
40+ * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
41+ * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
42+ * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
43+ * @property {string} variableName - The variable name being typed (after the prefix).
44+ * @property {number} variableNameEnd - The end of the variable name (for partial matches).
45+ * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.
46+ * @property {number} variableOperatorEnd - The end of the variable operator (for partial matches).
47+ * @property {string} variableValue - The value after the operator (for = and +=).
48+ * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.
49+ * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.
50+ * @property {boolean} isTypingValue - Whether cursor is after an operator that requires a value.
51+ * @property {boolean} [hasInvalidTrailingChars] - Whether there are invalid characters after the variable name.
52+ * @property {string} [invalidTrailingChars] - The invalid trailing characters (for error display).
53+ * @property {string} [partialOperator] - Partial operator prefix being typed ('+' or '-').
54+ * @property {boolean} [isOperatorComplete] - Whether a complete operator (++ or --) was typed that doesn't need a value.
55+ */
56+
57+/**
58+ * @typedef {Object} EnhancedMacroAutoCompleteOptions
59+ * @property {boolean} [noBraces=false] - If true, display without {{ }} braces (for use as values, e.g., in {{if}} conditions).
60+ * @property {string} [paddingAfter=''] - Whitespace to add before closing }} (for matching opening whitespace style).
61+ * @property {boolean} [closeWithBraces=false] - If true, the completion will add }} to close the macro.
62+ * @property {string[]} [flags=[]] - The currently already written flags for this autocomplete.
63+ * @property {string} [currentFlag] - The current flag that is present, if any.
64+ * @property {string} [fullText] - The currently written full text.
2565 */
2666
2767export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
@@ -31,17 +71,62 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
3171 /** @type {MacroAutoCompleteContext|null} */
3272 #context = null;
3373
74+ /** @type {EnhancedMacroAutoCompleteOptions|null} */
75+ #options = null;
76+
77+ /** @type {boolean} */
78+ #noBraces = false;
79+
80+ /** @type {string} */
81+ #paddingAfter = '';
82+
3483 /**
3584 * @param {MacroDefinition} macro - The macro definition from MacroRegistry.
3685 * @param {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} [contextcontextOrOptions] - Optional contextContext for argument hints, or options object.
3786 */
3887 constructor(macro, contextcontextOrOptions = null) {
3988 // Use the macro name as the autocomplete key
4089 super(macro.name, enumIcons.macro);
4190 this.#macro = macro;
42- this.#context = context;
91+
92+ // Detect if second argument is context or options
93+ // Context has 'identifier' property, options may have 'noBraces'
94+ if (contextOrOptions && typeof contextOrOptions === 'object') {
95+ if ('noBraces' in contextOrOptions || 'paddingAfter' in contextOrOptions || 'closeWithBraces' in contextOrOptions) {
96+ // It's an options object
97+ this.#options = /** @type {EnhancedMacroAutoCompleteOptions} */ (contextOrOptions);
98+ this.#noBraces = this.#options.noBraces ?? false;
99+ this.#paddingAfter = this.#options.paddingAfter ?? '';
100+
101+ // If noBraces mode with closeWithBraces, complete with name + padding + }}
102+ if (this.#options.closeWithBraces) {
103+ this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
104+ this.makeSelectable = true;
105+ }
106+ } else {
107+ // It's a context object
108+ this.#context = /** @type {MacroAutoCompleteContext} */ (contextOrOptions);
109+ }
110+ }
111+
43112 // nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
44- this.nameOffset = 2;
113+ // When noBraces is true, nameOffset = 0 since we don't show braces
114+ this.nameOffset = this.#noBraces ? 0 : 2;
115+
116+ // For macros that take no arguments, auto-complete with closing }} (unless already set by options)
117+ if (!this.valueProvider) {
118+ const takesNoArgs = macro.minArgs === 0 && macro.maxArgs === 0 && macro.list === null;
119+ if (takesNoArgs) {
120+ this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
121+ this.makeSelectable = true; // Required when using valueProvider
122+ }
123+ }
124+
125+ // {{//}} needs special handling. If we autocomplete right after **one** slash is already typed, we need to replace that, as it's treated as a flag otherwise.
126+ const fullText = this.#options?.fullText ?? this.#context?.fullText ?? '';
127+ if (macro.name === '//' && fullText.endsWith('/')) {
128+ this.replacementStartOffset = (this.replacementStartOffset ?? 0) - 1; // Cut the leading slash
129+ }
45130 }
46131
47132 /** @returns {MacroDefinition} */
@@ -74,8 +159,9 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
74159 const nameEl = document.createElement('span');
75160 nameEl.classList.add('name', 'monospace');
76161
77162 // Build signature with individual character spans (includes {{ }})
78- const sigText = formatMacroSignature(this.#macro);
163+ // When noBraces is true, show just the macro name without {{ }}
164+ const sigText = this.#noBraces ? this.#macro.name : formatMacroSignature(this.#macro);
79165 for (const char of sigText) {
80166 const span = document.createElement('span');
81167 span.textContent = char;
@@ -121,17 +207,36 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
121207 renderDetails() {
122208 const frag = document.createDocumentFragment();
123209
210+ // Check for arity warnings
211+ const warning = this.#getArityWarning();
212+ if (warning) {
213+ const warningEl = this.#renderWarning(warning);
214+ frag.append(warningEl);
215+ }
216+
217+ // Show scoped content info banner if we're in scoped content
218+ if (this.#context?.isInScopedContent) {
219+ const scopedInfo = this.#renderScopedContentInfo();
220+ if (scopedInfo) frag.append(scopedInfo);
221+ }
222+
124223 // Determine current argument index for highlighting
125224 const currentArgIndex = this.#context?.currentArgIndex ?? -1;
126225
226+ // For most warnings, we can still highlight which argument we are currently at.
227+ // This even goes for "too many arguments" when navigating the cursor back to
228+ // a valid argument.
229+ // Extend this in the future, if *some* warnings don't make sense to still highlight args.
230+ const hightlightArgsHint = currentArgIndex >= 0;
231+
127232 // Render argument hint banner if we're typing an argument
128233 if (hightlightArgsHint && currentArgIndex >= 0) {
129234 const hint = this.#renderArgumentHint();
130235 if (hint) frag.append(hint);
131236 }
132237
133238 // Reuse MacroBrowser's renderMacroDetails with options
134239 const details = renderMacroDetails(this.#macro, { currentArgIndex: hightlightArgsHint ? currentArgIndex : -1 });
135240
136241 // Add class for autocomplete-specific styling overrides
137242 details.classList.add('macro-ac-details');
@@ -141,6 +246,113 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
141246 }
142247
143248 /**
249+ * Checks for arity-related warnings based on the current context.
250+ * @returns {string|null} Warning message, or null if no warning.
251+ */
252+ #getArityWarning() {
253+ if (!this.#context) return null;
254+
255+ const argCount = this.#context.args.length;
256+ const maxArgs = this.#macro.maxArgs;
257+ //const minArgs = this.#macro.minArgs;
258+ const hasList = this.#macro.list !== null;
259+
260+ // Check for too many arguments (only if no list args)
261+ if (!hasList && argCount > maxArgs) {
262+ return `Too many arguments: this macro accepts ${maxArgs === 0 ? 'no arguments' : `up to ${maxArgs} argument${maxArgs === 1 ? '' : 's'}`}, but ${argCount} provided.`;
263+ }
264+
265+ // Check for space-separated arg on macro that doesn't support it
266+ // Space-separated syntax provides 1 arg; with scoped content you can provide a 2nd arg
267+ // So it's valid for macros with maxArgs <= 2 (or with list args)
268+ if (this.#context.hasSpaceArgContent) {
269+ if (maxArgs === 0 && !hasList) {
270+ return 'This macro does not accept any arguments. Remove the space or use a different macro.';
271+ }
272+ if (!hasList && maxArgs > 2) {
273+ return `Space-separated syntax only works for macros with up to 2 arguments. Use :: separators instead: {{${this.#macro.name}::arg1::arg2}}`;
274+ }
275+ }
276+
277+ // Check if trying to add args to a no-arg macro via ::
278+ // List-arg macros can accept args even if maxArgs === 0
279+ if (this.#context.separatorCount > 0 && maxArgs === 0 && !hasList) {
280+ return 'This macro does not accept any arguments.';
281+ }
282+
283+ // Check list bounds (min/max) if the macro has a list with constraints
284+ if (hasList && typeof this.#macro.list === 'object') {
285+ const listItemCount = Math.max(0, argCount - maxArgs);
286+ const listMin = this.#macro.list.min ?? 0;
287+ const listMax = this.#macro.list.max ?? null;
288+
289+ if (listItemCount < listMin) {
290+ const needed = listMin - listItemCount;
291+ return `Not enough list items yet: this macro requires at least ${listMin} item${listMin === 1 ? '' : 's'}, but only ${listItemCount} provided. Add ${needed} more.`;
292+ }
293+
294+ if (listMax !== null && listItemCount > listMax) {
295+ return `Too many list items: this macro accepts at most ${listMax} item${listMax === 1 ? '' : 's'}, but ${listItemCount} provided.`;
296+ }
297+ }
298+
299+ return null;
300+ }
301+
302+ /**
303+ * Renders a warning banner.
304+ * @param {string} message - The warning message.
305+ * @returns {HTMLElement}
306+ */
307+ #renderWarning(message) {
308+ const warning = document.createElement('div');
309+ warning.classList.add('macro-ac-warning');
310+
311+ const icon = document.createElement('i');
312+ icon.classList.add('fa-solid', 'fa-triangle-exclamation');
313+ warning.append(icon);
314+
315+ const text = document.createElement('span');
316+ text.textContent = message;
317+ warning.append(text);
318+
319+ return warning;
320+ }
321+
322+ /**
323+ * Renders the scoped content info banner.
324+ * Shows when cursor is inside scoped content of an unclosed macro.
325+ * @returns {HTMLElement|null}
326+ */
327+ #renderScopedContentInfo() {
328+ if (!this.#context?.isInScopedContent) return null;
329+
330+ const info = document.createElement('div');
331+ info.classList.add('macro-ac-scoped-info');
332+
333+ // If the scoped content is optional, show a prominent OPTIONAL badge
334+ if (this.#context.isScopedContentOptional) {
335+ const optionalBadge = document.createElement('span');
336+ optionalBadge.classList.add('macro-ac-optional-badge');
337+ optionalBadge.textContent = 'OPTIONAL';
338+ info.append(optionalBadge);
339+ }
340+
341+ const icon = document.createElement('i');
342+ icon.classList.add('fa-solid', 'fa-layer-group');
343+ info.append(icon);
344+
345+ const text = document.createElement('span');
346+ const closingHint = this.#context.isScopedContentOptional
347+ ? `Can optionally close with <code>{{/${this.#context.scopedMacroName}}}</code>`
348+ : `Close with <code>{{/${this.#context.scopedMacroName}}}</code>`;
349+ text.innerHTML = `Typing <strong>scoped content</strong> for <code>{{${this.#context.scopedMacroName}}}</code>. ${closingHint}`;
350+ info.append(text);
351+
352+ return info;
353+ }
354+
355+ /**
144356 * Renders the current argument hint banner.
145357 * @returns {HTMLElement|null}
146358 */
@@ -163,8 +375,23 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
163375 if (isListArg) {
164376 // List argument hint
165377 const listIndex = argIndex - this.#macro.maxArgs + 1;
378+ const totalListItems = this.#context.args.length - this.#macro.maxArgs;
379+
166380 const text = document.createElement('span');
167381 text.innerHTML = `<strong>List item ${listIndex}</strong>${(listIndex < totalListItems ? ` (of ${totalListItems})` : '')}`;
382+
383+ const listInfo = document.createElement('span');
384+ listInfo.classList.add('macro-ac-arg-hint-small');
385+ const minMax = [];
386+ if (this.#macro.list.min > 0) minMax.push(`min: ${this.#macro.list.min}`);
387+ if (this.#macro.list.max !== null) minMax.push(`max: ${this.#macro.list.max}`);
388+ if (minMax.length > 0) {
389+ listInfo.textContent = ` (list, ${minMax.join(', ')})`;
390+ } else {
391+ listInfo.textContent = ' (variable-length list)';
392+ }
393+ text.appendChild(listInfo);
394+
168395 hint.append(text);
169396 } else {
170397 // Unnamed argument hint (required or optional)
@@ -210,51 +437,1436 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
210437}
211438
212439/**
440+ * Autocomplete option for macro execution flags.
441+ * Shows flag symbol, name, and description.
442+ * Uses default AutoCompleteOption rendering for consistent styling.
443+ */
444+export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
445+ /** @type {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
446+ #flagDef;
447+
448+ /**
449+ * @param {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} flagDef - The flag definition.
450+ */
451+ constructor(flagDef) {
452+ // Use the flag symbol as the name, with a flag icon
453+ // Display name includes both symbol and name for clarity
454+ super(flagDef.type, '🚩');
455+ this.#flagDef = flagDef;
456+ }
457+
458+ /** @returns {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
459+ get flagDefinition() {
460+ return this.#flagDef;
461+ }
462+
463+ /**
464+ * Renders the autocomplete list item for this flag.
465+ * Uses the same structure as other autocomplete options for consistent styling.
466+ * @returns {HTMLElement}
467+ */
468+ renderItem() {
469+ // Use base class makeItem for consistent styling
470+ const li = this.makeItem(
471+ `${this.#flagDef.type} ${this.#flagDef.name}`, // Display: "? Optional"
472+ '🚩',
473+ true, // noSlash
474+ [], // namedArguments
475+ [], // unnamedArguments
476+ 'void', // returnType
477+ this.#flagDef.description + (this.#flagDef.implemented ? '' : ' (planned)'), // helpString
478+ );
479+ li.setAttribute('data-name', this.name);
480+ li.setAttribute('data-option-type', 'flag');
481+ return li;
482+ }
483+
484+ /**
485+ * Renders the details panel for this flag.
486+ * @returns {DocumentFragment}
487+ */
488+ renderDetails() {
489+ const frag = document.createDocumentFragment();
490+
491+ const details = document.createElement('div');
492+ details.classList.add('macro-flag-details');
493+
494+ // Header with flag symbol and name
495+ const header = document.createElement('h3');
496+ header.classList.add('macro-flag-details-header');
497+ header.innerHTML = `<code>${this.#flagDef.type}</code> ${this.#flagDef.name} Flag`;
498+ details.append(header);
499+
500+ // Description
501+ const desc = document.createElement('p');
502+ desc.classList.add('macro-flag-details-desc');
503+ desc.textContent = this.#flagDef.description;
504+ details.append(desc);
505+
506+ // Status
507+ const status = document.createElement('p');
508+ status.classList.add('macro-flag-details-status');
509+ status.innerHTML = `<strong>Status:</strong> ${this.#flagDef.implemented ? 'Implemented' : 'Planned for future release'}`;
510+ details.append(status);
511+
512+ // Parser effect note
513+ if (this.#flagDef.affectsParser) {
514+ const parserNote = document.createElement('p');
515+ parserNote.classList.add('macro-flag-details-note');
516+ parserNote.innerHTML = '<em>This flag affects how the macro is parsed.</em>';
517+ details.append(parserNote);
518+ }
519+
520+ frag.append(details);
521+ return frag;
522+ }
523+}
524+
525+/**
526+ * Enum of variable shorthand prefix types.
527+ * @readonly
528+ * @enum {string}
529+ */
530+export const VariableShorthandType = Object.freeze({
531+ /** Local variable prefix (`.`) */
532+ LOCAL: '.',
533+ /** Global variable prefix (`$`) */
534+ GLOBAL: '$',
535+});
536+
537+/**
538+ * @typedef {Object} VariableShorthandDefinition
539+ * @property {VariableShorthandType} type - The prefix symbol.
540+ * @property {string} name - Human-readable name.
541+ * @property {string} description - Description of what this prefix does.
542+ * @property {string[]} operations - List of supported operations.
543+ */
544+
545+/**
546+ * Definitions for variable shorthand prefixes.
547+ * @type {Map<string, VariableShorthandDefinition>}
548+ */
549+export const VariableShorthandDefinitions = new Map([
550+ [VariableShorthandType.LOCAL, {
551+ type: VariableShorthandType.LOCAL,
552+ name: 'Local Variable',
553+ description: 'Access or modify a local variable (scoped to current chat).',
554+ operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'],
555+ }],
556+ [VariableShorthandType.GLOBAL, {
557+ type: VariableShorthandType.GLOBAL,
558+ name: 'Global Variable',
559+ description: 'Access or modify a global variable (shared across all chats).',
560+ operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'],
561+ }],
562+]);
563+
564+/**
565+ * Set of valid variable shorthand prefix symbols.
566+ * @type {Set<string>}
567+ */
568+export const ValidVariableShorthandSymbols = new Set(Object.values(VariableShorthandType));
569+
570+/**
571+ * Regex pattern for valid variable shorthand names.
572+ * Must start with a letter, can contain word chars, underscores and hyphens, but must not end with an underscore or hyphen.
573+ * Examples: myVar, my-var, my_var, myVar123, my-long-var-name
574+ * Invalid: my-, my--, -var, 123var
575+ * @type {RegExp}
576+ */
577+const VARIABLE_SHORTHAND_NAME_PATTERN = new RegExp(`^${MACRO_VARIABLE_SHORTHAND_PATTERN.source}`);
578+
579+/**
580+ * Checks if a variable name is valid for use with variable shorthand syntax.
581+ * @param {string} name - The variable name to validate.
582+ * @returns {boolean} True if the name is valid for shorthand syntax.
583+ */
584+export function isValidVariableShorthandName(name) {
585+ if (!name || typeof name !== 'string') return false;
586+ return VARIABLE_SHORTHAND_NAME_PATTERN.test(name);
587+}
588+
589+/**
590+ * Autocomplete option for variable shorthand prefixes.
591+ * Shows prefix symbol, name, and description.
592+ * This provides entry into the variable shorthand syntax ({{.varName}} or {{$varName}}).
593+ */
594+export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
595+ /** @type {VariableShorthandDefinition} */
596+ #varDef;
597+
598+ /**
599+ * @param {VariableShorthandDefinition} varDef - The variable shorthand definition.
600+ */
601+ constructor(varDef) {
602+ // Use the prefix symbol as the name, with a variable icon
603+ super(varDef.type, '📦');
604+ this.#varDef = varDef;
605+ }
606+
607+ /** @returns {VariableShorthandDefinition} */
608+ get variableDefinition() {
609+ return this.#varDef;
610+ }
611+
612+ /**
613+ * Renders the autocomplete list item for this variable shorthand.
614+ * @returns {HTMLElement}
615+ */
616+ renderItem() {
617+ const li = this.makeItem(
618+ `${this.#varDef.type} ${this.#varDef.name}`,
619+ '📦',
620+ true, // noSlash
621+ [], // namedArguments
622+ [], // unnamedArguments
623+ 'any', // returnType
624+ this.#varDef.description,
625+ );
626+ li.setAttribute('data-name', this.name);
627+ li.setAttribute('data-option-type', 'variable-shorthand');
628+ return li;
629+ }
630+
631+ /**
632+ * Renders the details panel for this variable shorthand.
633+ * @returns {DocumentFragment}
634+ */
635+ renderDetails() {
636+ const frag = document.createDocumentFragment();
637+
638+ const details = document.createElement('div');
639+ details.classList.add('macro-variable-details');
640+
641+ // Header with prefix symbol and name
642+ const header = document.createElement('h3');
643+ header.classList.add('macro-variable-details-header');
644+ header.innerHTML = `<code>${this.#varDef.type}</code> ${this.#varDef.name}`;
645+ details.append(header);
646+
647+ // Description
648+ const desc = document.createElement('p');
649+ desc.classList.add('macro-variable-details-desc');
650+ desc.textContent = this.#varDef.description;
651+ details.append(desc);
652+
653+ // Supported operations
654+ const opsHeader = document.createElement('p');
655+ opsHeader.innerHTML = '<strong>Supported Operations:</strong>';
656+ details.append(opsHeader);
657+
658+ const opsList = document.createElement('ul');
659+ opsList.classList.add('macro-variable-details-ops');
660+ for (const op of this.#varDef.operations) {
661+ const li = document.createElement('li');
662+ li.textContent = op;
663+ opsList.append(li);
664+ }
665+ details.append(opsList);
666+
667+ // Examples
668+ const exampleHeader = document.createElement('p');
669+ exampleHeader.innerHTML = '<strong>Examples:</strong>';
670+ details.append(exampleHeader);
671+
672+ const exampleList = document.createElement('ul');
673+ exampleList.classList.add('macro-variable-details-examples');
674+ const prefix = this.#varDef.type;
675+ const examples = [
676+ `{{${prefix}myvar}} - Get variable value`,
677+ `{{${prefix}myvar = value}} - Set variable (returns nothing)`,
678+ `{{${prefix}counter++}} - Increment and get value`,
679+ `{{${prefix}counter--}} - Decrement and get value`,
680+ `{{${prefix}myvar += text}} - Append/add (returns nothing)`,
681+ `{{${prefix}score -= 5}} - Subtract (returns nothing)`,
682+ `{{${prefix}myvar || default}} - Get with fallback if falsy`,
683+ `{{${prefix}myvar ?? default}} - Get with fallback if undefined`,
684+ `{{${prefix}myvar ||= value}} - Set if falsy, get value`,
685+ `{{${prefix}myvar ??= value}} - Set if undefined, get value`,
686+ `{{${prefix}myvar == test}} - Compare (returns true/false)`,
687+ `{{${prefix}myvar != test}} - Compare not equal (returns true/false)`,
688+ `{{${prefix}score > 10}} - Greater than (numeric, returns true/false)`,
689+ `{{${prefix}score >= 10}} - Greater than or equal (numeric)`,
690+ `{{${prefix}score < 10}} - Less than (numeric, returns true/false)`,
691+ `{{${prefix}score <= 10}} - Less than or equal (numeric)`,
692+ ];
693+ for (const ex of examples) {
694+ const li = document.createElement('li');
695+ li.innerHTML = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
696+ exampleList.append(li);
697+ }
698+ details.append(exampleList);
699+
700+ frag.append(details);
701+ return frag;
702+ }
703+}
704+
705+/**
706+ * Autocomplete option for a specific variable name.
707+ * Shows variable name with scope indicator (local/global).
708+ */
709+export class VariableNameAutoCompleteOption extends AutoCompleteOption {
710+ /** @type {string} */
711+ #varName;
712+
713+ /** @type {'local'|'global'} */
714+ #scope;
715+
716+ /** @type {boolean} */
717+ #isNewVariable;
718+
719+ /** @type {boolean} */
720+ #isInvalidName;
721+
722+ /**
723+ * @param {string} varName - The variable name.
724+ * @param {'local'|'global'} scope - Whether this is a local or global variable.
725+ * @param {boolean} [isNewVariable=false] - Whether this is a "create new variable" option.
726+ * @param {boolean} [isInvalidName=false] - Whether this name is invalid for shorthand syntax.
727+ */
728+ constructor(varName, scope, isNewVariable = false, isInvalidName = false) {
729+ const icon = scope === 'local' ? 'L' : 'G';
730+ super(varName, icon);
731+ this.#varName = varName;
732+ this.#scope = scope;
733+ this.#isNewVariable = isNewVariable;
734+ this.#isInvalidName = isInvalidName;
735+ }
736+
737+ /** @returns {string} */
738+ get variableName() {
739+ return this.#varName;
740+ }
741+
742+ /** @returns {'local'|'global'} */
743+ get scope() {
744+ return this.#scope;
745+ }
746+
747+ /** @returns {boolean} */
748+ get isNewVariable() {
749+ return this.#isNewVariable;
750+ }
751+
752+ /** @returns {boolean} */
753+ get isInvalidName() {
754+ return this.#isInvalidName;
755+ }
756+
757+ /**
758+ * Renders the autocomplete list item for this variable.
759+ * @returns {HTMLElement}
760+ */
761+ renderItem() {
762+ const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
763+ let description;
764+ if (this.#isInvalidName) {
765+ description = '⚠️ Invalid variable name for shorthand';
766+ } else if (this.#isNewVariable) {
767+ description = `Define new ${scopeLabel.toLowerCase()} variable`;
768+ } else {
769+ description = `${scopeLabel} variable`;
770+ }
771+
772+ const li = this.makeItem(
773+ this.#varName,
774+ this.typeIcon,
775+ true, // noSlash
776+ [], // namedArguments
777+ [], // unnamedArguments
778+ 'any', // returnType
779+ description,
780+ );
781+ li.setAttribute('data-name', this.name);
782+ li.setAttribute('data-option-type', 'variable-name');
783+ if (this.#isNewVariable) {
784+ li.classList.add('variable-new');
785+ }
786+ if (this.#isInvalidName) {
787+ li.classList.add('variable-invalid');
788+ }
789+ return li;
790+ }
791+
792+ /**
793+ * Renders the details panel for this variable.
794+ * @returns {DocumentFragment}
795+ */
796+ renderDetails() {
797+ const frag = document.createDocumentFragment();
798+
799+ const details = document.createElement('div');
800+ details.classList.add('macro-variable-name-details');
801+
802+ const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
803+ const prefix = this.#scope === 'local' ? '.' : '$';
804+
805+ // Show big warning for invalid names
806+ if (this.#isInvalidName) {
807+ const warningBox = document.createElement('div');
808+ warningBox.classList.add('variable-invalid-warning');
809+ warningBox.style.cssText = 'background: #ff000033; border: 2px solid #ff0000; border-radius: 4px; padding: 10px; margin-bottom: 10px;';
810+
811+ const warningHeader = document.createElement('h3');
812+ warningHeader.style.cssText = 'color: #ff6b6b; margin: 0 0 8px 0;';
813+ warningHeader.textContent = '⚠️ Invalid Variable Name';
814+ warningBox.append(warningHeader);
815+
816+ const warningText = document.createElement('p');
817+ warningText.style.cssText = 'margin: 0 0 8px 0;';
818+ warningText.innerHTML = `The name <code>${this.#varName}</code> cannot be used with variable shorthand syntax.`;
819+ warningBox.append(warningText);
820+
821+ const rulesText = document.createElement('p');
822+ rulesText.style.cssText = 'margin: 0; font-size: 0.9em;';
823+ rulesText.innerHTML = '<strong>Valid names must:</strong><br>• Start with a letter (a-z, A-Z)<br>• Contain only letters, numbers, underscores, or hyphens<br>• Not end with an underscore or hyphen';
824+ warningBox.append(rulesText);
825+
826+ details.append(warningBox);
827+ frag.append(details);
828+ return frag;
829+ }
830+
831+ // Header
832+ const header = document.createElement('h3');
833+ header.innerHTML = this.#isNewVariable
834+ ? `<code>${prefix}${this.#varName}</code> (New ${scopeLabel} Variable)`
835+ : `<code>${prefix}${this.#varName}</code> ${scopeLabel} Variable`;
836+ details.append(header);
837+
838+ // Description
839+ const desc = document.createElement('p');
840+ const variableSuggestion = this.#scope === 'local'
841+ ? 'Local variables are scoped to the current chat.'
842+ : 'Global variables are shared across all chats.';
843+ if (this.#isNewVariable) {
844+ desc.textContent = `Creates a new ${scopeLabel.toLowerCase()} variable named "${this.#varName}". ${variableSuggestion}`;
845+ } else {
846+ desc.textContent = `Access or modify the ${scopeLabel.toLowerCase()} variable "${this.#varName}". ${variableSuggestion}`;
847+ }
848+ details.append(desc);
849+
850+ // Usage examples
851+ const usageHeader = document.createElement('p');
852+ usageHeader.innerHTML = '<strong>Usage:</strong>';
853+ details.append(usageHeader);
854+
855+ const usageList = document.createElement('ul');
856+ const examples = [
857+ `{{${prefix}${this.#varName}}} - Get value`,
858+ `{{${prefix}${this.#varName} = value}} - Set value`,
859+ `{{${prefix}${this.#varName}++}} - Increment`,
860+ `{{${prefix}${this.#varName}--}} - Decrement`,
861+ `{{${prefix}${this.#varName} += text}} - Append/add`,
862+ `{{${prefix}${this.#varName} -= 5}} - Subtract`,
863+ `{{${prefix}${this.#varName} || default}} - Get with fallback if falsy`,
864+ `{{${prefix}${this.#varName} ?? default}} - Get with fallback if undefined`,
865+ `{{${prefix}${this.#varName} ||= value}} - Set if falsy, get value`,
866+ `{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`,
867+ `{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`,
868+ `{{${prefix}${this.#varName} != test}} - Compare not equal (returns true/false)`,
869+ `{{${prefix}${this.#varName} > 10}} - Greater than (numeric)`,
870+ `{{${prefix}${this.#varName} >= 10}} - Greater than or equal (numeric)`,
871+ `{{${prefix}${this.#varName} < 10}} - Less than (numeric)`,
872+ `{{${prefix}${this.#varName} <= 10}} - Less than or equal (numeric)`,
873+ ];
874+ for (const ex of examples) {
875+ const li = document.createElement('li');
876+ li.innerHTML = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
877+ usageList.append(li);
878+ }
879+ details.append(usageList);
880+
881+ frag.append(details);
882+ return frag;
883+ }
884+}
885+
886+/**
887+ * Checks if an operator is a short one that could be a prefix of a longer operator.
888+ * For example, '>' is a prefix of '>=', '<' is a prefix of '<='.
889+ * @param {string} op - The operator to check.
890+ * @returns {boolean} True if the operator could be a prefix of a longer operator.
891+ */
892+function isShortOperatorPrefix(op) {
893+ // These operators could have longer variants typed after them
894+ const shortPrefixes = ['>', '<', '=', '|', '?', '+', '-', '!'];
895+ return shortPrefixes.includes(op);
896+}
897+
898+/**
899+ * Variable shorthand operators with metadata.
900+ * @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>}
901+ */
902+export const VariableOperatorDefinitions = new Map([
903+ ['=', {
904+ symbol: '=',
905+ name: 'Set',
906+ description: 'Set the variable to a new value. Returns nothing.',
907+ needsValue: true,
908+ }],
909+ ['++', {
910+ symbol: '++',
911+ name: 'Increment',
912+ description: 'Increment the variable by 1 (numeric). Returns the new value.',
913+ needsValue: false,
914+ }],
915+ ['--', {
916+ symbol: '--',
917+ name: 'Decrement',
918+ description: 'Decrement the variable by 1 (numeric). Returns the new value.',
919+ needsValue: false,
920+ }],
921+ ['+=', {
922+ symbol: '+=',
923+ name: 'Add',
924+ description: 'Add to the variable (numeric addition or string concatenation). Returns nothing.',
925+ needsValue: true,
926+ }],
927+ ['-=', {
928+ symbol: '-=',
929+ name: 'Subtract',
930+ description: 'Subtract a numeric value from the variable. Returns nothing.',
931+ needsValue: true,
932+ }],
933+ ['||', {
934+ symbol: '||',
935+ name: 'Logical Or',
936+ description: 'Return the fallback value if the variable is falsy, otherwise return the variable value.',
937+ needsValue: true,
938+ }],
939+ ['??', {
940+ symbol: '??',
941+ name: 'Nullish Coalescing',
942+ description: 'Return the fallback value only if the variable does not exist, otherwise return the variable value (even if falsy).',
943+ needsValue: true,
944+ }],
945+ ['||=', {
946+ symbol: '||=',
947+ name: 'Logical Or Assign',
948+ description: 'If the variable is falsy, set it to the value and return it; otherwise return the current value.',
949+ needsValue: true,
950+ }],
951+ ['??=', {
952+ symbol: '??=',
953+ name: 'Nullish Coalescing Assign',
954+ description: 'If the variable does not exist, set it to the value and return it; otherwise return the current value.',
955+ needsValue: true,
956+ }],
957+ ['==', {
958+ symbol: '==',
959+ name: 'Equals',
960+ description: 'Compare the variable value to another value. Returns "true" or "false".',
961+ needsValue: true,
962+ }],
963+ ['!=', {
964+ symbol: '!=',
965+ name: 'Not Equals',
966+ description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.',
967+ needsValue: true,
968+ }],
969+ ['>', {
970+ symbol: '>',
971+ name: 'Greater Than',
972+ description: 'Numeric comparison. Returns "true" if variable is greater than value, "false" otherwise.',
973+ needsValue: true,
974+ }],
975+ ['>=', {
976+ symbol: '>=',
977+ name: 'Greater Than or Equal',
978+ description: 'Numeric comparison. Returns "true" if variable is greater than or equal to value, "false" otherwise.',
979+ needsValue: true,
980+ }],
981+ ['<', {
982+ symbol: '<',
983+ name: 'Less Than',
984+ description: 'Numeric comparison. Returns "true" if variable is less than value, "false" otherwise.',
985+ needsValue: true,
986+ }],
987+ ['<=', {
988+ symbol: '<=',
989+ name: 'Less Than or Equal',
990+ description: 'Numeric comparison. Returns "true" if variable is less than or equal to value, "false" otherwise.',
991+ needsValue: true,
992+ }],
993+]);
994+
995+/**
996+ * Autocomplete option for a variable operator.
997+ * Shows operator symbol, name, and description.
998+ */
999+export class VariableOperatorAutoCompleteOption extends AutoCompleteOption {
1000+ /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1001+ #operatorDef;
1002+
1003+ /**
1004+ * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
1005+ */
1006+ constructor(operatorDef) {
1007+ super(operatorDef.symbol, '⚡');
1008+ this.#operatorDef = operatorDef;
1009+ }
1010+
1011+ /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1012+ get operatorDefinition() {
1013+ return this.#operatorDef;
1014+ }
1015+
1016+ /**
1017+ * Renders the autocomplete list item for this operator.
1018+ * @returns {HTMLElement}
1019+ */
1020+ renderItem() {
1021+ const li = this.makeItem(
1022+ `${this.#operatorDef.symbol} ${this.#operatorDef.name}`,
1023+ '⚡',
1024+ true, // noSlash
1025+ [], // namedArguments
1026+ [], // unnamedArguments
1027+ 'void', // returnType
1028+ this.#operatorDef.description,
1029+ );
1030+ li.setAttribute('data-name', this.name);
1031+ li.setAttribute('data-option-type', 'variable-operator');
1032+ return li;
1033+ }
1034+
1035+ /**
1036+ * Renders the details panel for this operator.
1037+ * @returns {DocumentFragment}
1038+ */
1039+ renderDetails() {
1040+ const frag = document.createDocumentFragment();
1041+
1042+ const details = document.createElement('div');
1043+ details.classList.add('macro-variable-operator-details');
1044+
1045+ // Header
1046+ const header = document.createElement('h3');
1047+ header.innerHTML = `<code>${this.#operatorDef.symbol}</code> ${this.#operatorDef.name}`;
1048+ details.append(header);
1049+
1050+ // Description
1051+ const desc = document.createElement('p');
1052+ desc.textContent = this.#operatorDef.description;
1053+ details.append(desc);
1054+
1055+ // Value note
1056+ const valueNote = document.createElement('p');
1057+ valueNote.innerHTML = this.#operatorDef.needsValue
1058+ ? '<em>This operator requires a value after it.</em>'
1059+ : '<em>This operator does not take a value.</em>';
1060+ details.append(valueNote);
1061+
1062+ frag.append(details);
1063+ return frag;
1064+ }
1065+}
1066+
1067+/**
1068+ * Non-selectable autocomplete option that shows context about the value being typed.
1069+ * Displays info about what value is expected based on the operator.
1070+ */
1071+export class VariableValueContextAutoCompleteOption extends AutoCompleteOption {
1072+ /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1073+ #operatorDef;
1074+
1075+ /** @type {string} */
1076+ #currentValue;
1077+
1078+ /**
1079+ * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
1080+ * @param {string} [currentValue=''] - The value currently being typed.
1081+ */
1082+ constructor(operatorDef, currentValue = '') {
1083+ super('value', '📝');
1084+ this.#operatorDef = operatorDef;
1085+ this.#currentValue = currentValue;
1086+ this.forceFullNameMatch = true;
1087+ }
1088+
1089+ /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1090+ get operatorDefinition() {
1091+ return this.#operatorDef;
1092+ }
1093+
1094+ /**
1095+ * Renders the autocomplete list item for this value context.
1096+ * @returns {HTMLElement}
1097+ */
1098+ renderItem() {
1099+ const li = this.makeItem(
1100+ '<value>',
1101+ '📝',
1102+ true, // noSlash
1103+ [], // namedArguments
1104+ [], // unnamedArguments
1105+ 'any', // returnType
1106+ `${this.#operatorDef.name} (${this.#operatorDef.symbol}) expects a value`,
1107+ );
1108+ li.setAttribute('data-name', this.name);
1109+ li.setAttribute('data-option-type', 'variable-value-context');
1110+ return li;
1111+ }
1112+
1113+ /**
1114+ * Renders the details panel for this value context.
1115+ * @returns {DocumentFragment}
1116+ */
1117+ renderDetails() {
1118+ const frag = document.createDocumentFragment();
1119+
1120+ const details = document.createElement('div');
1121+ details.classList.add('macro-variable-value-context-details');
1122+
1123+ // Header
1124+ const header = document.createElement('h3');
1125+ header.innerHTML = `Value for <code>${this.#operatorDef.symbol}</code> (${this.#operatorDef.name})`;
1126+ details.append(header);
1127+
1128+ // Description of what value is expected
1129+ const desc = document.createElement('p');
1130+ desc.textContent = this.#operatorDef.description;
1131+ details.append(desc);
1132+
1133+ // Current value being typed
1134+ if (this.#currentValue) {
1135+ const currentNote = document.createElement('p');
1136+ currentNote.innerHTML = `<em>Currently typing:</em> <code>${this.#currentValue}</code>`;
1137+ details.append(currentNote);
1138+ }
1139+
1140+ // Hint
1141+ const hint = document.createElement('p');
1142+ hint.classList.add('hint');
1143+ hint.innerHTML = '<em>Type your value and close with <code>}}</code> to complete the macro.</em>';
1144+ details.append(hint);
1145+
1146+ frag.append(details);
1147+ return frag;
1148+ }
1149+}
1150+
1151+/**
1152+ * Autocomplete option for closing a scoped macro.
1153+ * Suggests {{/macroName}} to close an unclosed scoped macro.
1154+ */
1155+export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
1156+ /** @type {string} */
1157+ #macroName;
1158+
1159+ /** @type {string} */
1160+ #paddingBefore;
1161+
1162+ /** @type {string} */
1163+ #paddingAfter;
1164+
1165+ /** @type {boolean} */
1166+ #isOptional;
1167+
1168+ /** @type {number} */
1169+ #nestingLevel;
1170+
1171+ /**
1172+ * @param {string} macroName - The name of the macro to close.
1173+ * @param {Object} [options] - Optional configuration.
1174+ * @param {string} [options.paddingBefore=''] - Whitespace after {{ in opening tag (target padding).
1175+ * @param {string} [options.paddingAfter=''] - Whitespace before }} in opening tag (target padding).
1176+ * @param {string} [options.currentPadding=''] - Whitespace the user has already typed after {{.
1177+ * @param {boolean} [options.isOptional=false] - Whether this closing tag is for an optional scope.
1178+ * @param {number} [options.nestingLevel=0] - Nesting level (0 = innermost).
1179+ */
1180+ constructor(macroName, options = {}) {
1181+ // The closing tag is what we're suggesting - use /macroName as the name for matching
1182+ const closingTag = `/${macroName}`;
1183+ super(closingTag, '{/');
1184+ this.#macroName = macroName;
1185+ this.#paddingBefore = options.paddingBefore ?? '';
1186+ this.#paddingAfter = options.paddingAfter ?? '';
1187+ this.#isOptional = options.isOptional ?? false;
1188+ this.#nestingLevel = options.nestingLevel ?? 0;
1189+
1190+ // Calculate the replacement offset to replace any existing whitespace the user typed
1191+ // This allows us to normalize the whitespace to match the opening tag's style
1192+ const currentPadding = options.currentPadding ?? '';
1193+ // Negative offset to start replacement earlier (eating the user's whitespace)
1194+ this.replacementStartOffset = -currentPadding.length;
1195+
1196+ // Custom valueProvider to return the correct replacement text
1197+ // Includes the target paddingBefore from the opening tag, replacing any user-typed whitespace
1198+ this.valueProvider = () => {
1199+ // Return: paddingBefore + /macroName + paddingAfter + }}
1200+ return `${this.#paddingBefore}/${macroName}${this.#paddingAfter}}}`;
1201+ };
1202+
1203+ // Make selectable so TAB completion works (valueProvider alone makes it non-selectable)
1204+ this.makeSelectable = true;
1205+
1206+ // nameOffset = 2 to skip the {{ prefix in the display for fuzzy highlighting
1207+ // The name is /macroName but display shows {{/macroName}}
1208+ this.nameOffset = 2;
1209+
1210+ // Highest priority - closing tags should always appear at the very top
1211+ this.sortPriority = 1;
1212+ }
1213+
1214+ /** @returns {string} */
1215+ get macroName() {
1216+ return this.#macroName;
1217+ }
1218+
1219+ /**
1220+ * Renders the autocomplete list item for this closing tag.
1221+ * Uses the same structure as other macro options for consistent styling.
1222+ * @returns {HTMLElement}
1223+ */
1224+ renderItem() {
1225+ const li = document.createElement('li');
1226+ li.classList.add('item', 'macro-ac-item');
1227+
1228+ // Type icon (same column as other macros)
1229+ const type = document.createElement('span');
1230+ type.classList.add('type', 'monospace');
1231+ type.textContent = this.typeIcon;
1232+ li.append(type);
1233+
1234+ // Specs container (for fuzzy highlight compatibility)
1235+ const specs = document.createElement('span');
1236+ specs.classList.add('specs');
1237+
1238+ // Name element with character spans
1239+ const nameEl = document.createElement('span');
1240+ nameEl.classList.add('name', 'monospace');
1241+ // Display full closing tag like other macros show full syntax
1242+ const displayName = `{{/${this.#macroName}}}`;
1243+ for (const char of displayName) {
1244+ const span = document.createElement('span');
1245+ span.textContent = char;
1246+ nameEl.append(span);
1247+ }
1248+ specs.append(nameEl);
1249+ li.append(specs);
1250+
1251+ // Stopgap (spacer for flex layout)
1252+ const stopgap = document.createElement('span');
1253+ stopgap.classList.add('stopgap');
1254+ li.append(stopgap);
1255+
1256+ // Help text (description)
1257+ const help = document.createElement('span');
1258+ help.classList.add('help');
1259+ const content = document.createElement('span');
1260+ content.classList.add('helpContent');
1261+
1262+ // Build description based on optional status and nesting
1263+ if (this.#isOptional) {
1264+ const optionalBadge = document.createElement('span');
1265+ optionalBadge.classList.add('macro-ac-optional-badge', 'macro-ac-optional-badge-small');
1266+ optionalBadge.textContent = 'OPTIONAL';
1267+ content.append(optionalBadge);
1268+ content.append(' ');
1269+
1270+ const nestingInfo = this.#nestingLevel > 0 ? ` (nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep)` : '';
1271+ content.append(document.createTextNode(`Optionally close {{${this.#macroName}}}${nestingInfo}`));
1272+ } else {
1273+ content.textContent = `Close the {{${this.#macroName}}} scoped macro.`;
1274+ }
1275+
1276+ help.append(content);
1277+ li.append(help);
1278+
1279+ return li;
1280+ }
1281+
1282+ /**
1283+ * Renders the details panel for this closing tag.
1284+ * @returns {DocumentFragment}
1285+ */
1286+ renderDetails() {
1287+ const frag = document.createDocumentFragment();
1288+
1289+ const details = document.createElement('div');
1290+ details.classList.add('macro-closing-tag-details');
1291+
1292+ // If optional, show badge at the top
1293+ if (this.#isOptional) {
1294+ const optionalBadge = document.createElement('span');
1295+ optionalBadge.classList.add('macro-ac-optional-badge');
1296+ optionalBadge.textContent = 'OPTIONAL';
1297+ details.append(optionalBadge);
1298+ }
1299+
1300+ // Header
1301+ const header = document.createElement('h3');
1302+ header.innerHTML = `Close <code>{{${this.#macroName}}}</code>`;
1303+ details.append(header);
1304+
1305+ // Description
1306+ const desc = document.createElement('p');
1307+ if (this.#isOptional) {
1308+ const nestingInfo = this.#nestingLevel > 0 ? ` This scope is nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep.` : '';
1309+ desc.textContent = `Optionally inserts the closing tag {{/${this.#macroName}}}. The scoped content for this macro is optional - you can close it or leave it open.${nestingInfo}`;
1310+ } else {
1311+ desc.textContent = `Inserts the closing tag {{/${this.#macroName}}} to complete the scoped macro. The content between the opening and closing tags will be passed as the last argument.`;
1312+ }
1313+ details.append(desc);
1314+
1315+ frag.append(details);
1316+ return frag;
1317+ }
1318+}
1319+
1320+/**
2131321 * Parses the macro text to determine current argument context.
214- * @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "random::a::b".
1322+ * Handles leading whitespace and flags before the identifier.
1323+ *
1324+ * @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "!user" or " description ".
2151325 * @param {number} cursorOffset - Cursor position within macroText.
2161326 * @returns {MacroAutoCompleteContext}
2171327 */
2181328export function parseMacroContext(macroText, cursorOffset) {
219- const parts = [];
220- let currentPart = '';
221- let partStart = 0;
2221329 let i = 0;
2231330
1331+ // Skip leading whitespace (but NOT newlines - those stop macro parsing for autocomplete)
1332+ while (i < macroText.length && /[ \t]/.test(macroText[i])) {
1333+ i++;
1334+ }
1335+
1336+ // Extract flags (special symbols before the identifier)
1337+ // Track position after each flag to determine which flag cursor is on
1338+ // Special case: `/` followed by identifier chars is a closing tag, not a flag
1339+ const flags = [];
1340+ const flagEndPositions = []; // Position right after each flag (before any whitespace)
2241341 while (i < macroText.length) {
225- if (macroText[i] === ':' && macroText[i + 1] === ':') {
1342+ const char = macroText[i];
226- parts.push({ text: currentPart, start: partStart, end: i });
1343+ // Check if this looks like a closing tag: `/` followed by an identifier character
227- currentPart = '';
1344+ if (char === '/' && i + 1 < macroText.length && /[a-zA-Z/]/.test(macroText[i + 1])) {
228- i += 2;
1345+ // This is a closing tag identifier, not a flag - stop parsing flags
229- partStart = i;
1346+ break;
1347+ }
1348+ if (ValidFlagSymbols.has(char)) {
1349+ flags.push(char);
1350+ i++;
1351+ flagEndPositions.push(i); // Position right after this flag
1352+ // Skip whitespace between flags (but NOT newlines - those stop macro parsing for autocomplete)
1353+ while (i < macroText.length && /[ \t]/.test(macroText[i])) {
1354+ i++;
1355+ }
2301356 } else {
231- currentPart += macroText[i];
1357+ break;
1358+ }
1359+ }
1360+
1361+ // Determine which flag cursor is currently on (if any)
1362+ // The "current" flag is the last one typed when cursor is still in the flags area
1363+ // This ensures the last typed flag shows at the top of the autocomplete list
1364+ let currentFlag = null;
1365+ if (flags.length > 0) {
1366+ // If cursor is at or after the last flag position but before identifier starts,
1367+ // the last flag is the "current" one (just typed)
1368+ const lastFlagEnd = flagEndPositions[flagEndPositions.length - 1];
1369+ if (cursorOffset >= lastFlagEnd - 1) {
1370+ currentFlag = flags[flags.length - 1];
1371+ }
1372+ }
1373+
1374+ if (flags.length > 0) {
1375+ void onboardingExperimentalMacroEngine('macro flags');
1376+ }
1377+
1378+ // Check for variable shorthand prefix (. or $)
1379+ // These trigger variable expression mode instead of regular macro parsing
1380+ /** @type {'.'|'$'|null} */
1381+ let variablePrefix = null;
1382+ let variableName = '';
1383+ /** @type {string|null} */
1384+ let variableOperator = null;
1385+ let variableValue = '';
1386+ let isVariableShorthand = false;
1387+ let isTypingVariableName = false;
1388+ let isTypingOperator = false;
1389+ let isTypingValue = false;
1390+ let variableNameEnd = i;
1391+
1392+ const remainingAfterFlags = macroText.slice(i);
1393+ if (remainingAfterFlags.startsWith('.') || remainingAfterFlags.startsWith('$')) {
1394+ isVariableShorthand = true;
1395+ variablePrefix = /** @type {'.'|'$'} */ (remainingAfterFlags[0]);
1396+ i++; // Move past the prefix
1397+
1398+ // Variable names: start with letter, can have hyphens inside, must not end with hyphen
1399+ const varNameMatch = macroText.slice(i).match(VARIABLE_SHORTHAND_NAME_PATTERN);
1400+ if (varNameMatch) {
1401+ variableName = varNameMatch[0];
1402+ i += variableName.length;
1403+ }
1404+ variableNameEnd = i;
1405+
1406+ // Skip whitespace before operator
1407+ while (i < macroText.length && /\s/.test(macroText[i])) {
2321408 i++;
2331409 }
1410+
1411+ // Check for operators: ++, --, +=, -=, ||=, ??=, ||, ??, ==, =
1412+ // Order matters: longer operators must be checked before shorter ones
1413+ // Also track partial operator prefixes for autocomplete
1414+ const operatorText = macroText.slice(i);
1415+ let hasInvalidTrailingChars = false;
1416+ let invalidTrailingChars = '';
1417+ let partialOperator = '';
1418+ if (operatorText.startsWith('++')) {
1419+ variableOperator = '++';
1420+ i += 2;
1421+ } else if (operatorText.startsWith('--')) {
1422+ variableOperator = '--';
1423+ i += 2;
1424+ } else if (operatorText.startsWith('||=')) {
1425+ variableOperator = '||=';
1426+ i += 3;
1427+ } else if (operatorText.startsWith('??=')) {
1428+ variableOperator = '??=';
1429+ i += 3;
1430+ } else if (operatorText.startsWith('||')) {
1431+ variableOperator = '||';
1432+ i += 2;
1433+ } else if (operatorText.startsWith('??')) {
1434+ variableOperator = '??';
1435+ i += 2;
1436+ } else if (operatorText.startsWith('+=')) {
1437+ variableOperator = '+=';
1438+ i += 2;
1439+ } else if (operatorText.startsWith('-=')) {
1440+ variableOperator = '-=';
1441+ i += 2;
1442+ } else if (operatorText.startsWith('==')) {
1443+ variableOperator = '==';
1444+ i += 2;
1445+ } else if (operatorText.startsWith('!=')) {
1446+ variableOperator = '!=';
1447+ i += 2;
1448+ } else if (operatorText.startsWith('>=')) {
1449+ variableOperator = '>=';
1450+ i += 2;
1451+ } else if (operatorText.startsWith('>')) {
1452+ variableOperator = '>';
1453+ i += 1;
1454+ } else if (operatorText.startsWith('<=')) {
1455+ variableOperator = '<=';
1456+ i += 2;
1457+ } else if (operatorText.startsWith('<')) {
1458+ variableOperator = '<';
1459+ i += 1;
1460+ } else if (operatorText.startsWith('=')) {
1461+ variableOperator = '=';
1462+ i += 1;
1463+ } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!') || operatorText.startsWith('>') || operatorText.startsWith('<')) {
1464+ // Partial operator prefix - user is typing an operator
1465+ partialOperator = operatorText[0];
1466+ } else if (operatorText.length > 0 && !/^\s/.test(operatorText) && !operatorText.startsWith('}')) {
1467+ // There's non-whitespace after the variable name that isn't a valid operator
1468+ // This is an invalid trailing character (e.g., $my$ or .var@test)
1469+ // Exception: } is the closing brace, not an invalid char
1470+ hasInvalidTrailingChars = true;
1471+ invalidTrailingChars = operatorText.trim();
1472+ }
1473+
1474+ // Track where the operator ends (for cursor position checks)
1475+ const variableOperatorEnd = i;
1476+
1477+ // Check if operator requires a value
1478+ const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null;
1479+ const operatorNeedsValue = operatorDef?.needsValue ?? false;
1480+
1481+ // If operator requires a value, parse the value
1482+ // Do this BEFORE isTypingClosingBrace detection so we can check for } in value area
1483+ // let valueStartPos = i;
1484+ if (operatorNeedsValue) {
1485+ // Skip whitespace after operator
1486+ while (i < macroText.length && /\s/.test(macroText[i])) {
1487+ i++;
1488+ }
1489+ // valueStartPos = i;
1490+ variableValue = macroText.slice(i).trimEnd();
1491+ }
1492+
1493+ // Detect if typing first closing brace on a variable shorthand
1494+ // This happens when operatorText is just "}" or when cursor is beyond content (after }})
1495+ let isTypingClosingBrace = false;
1496+ if (operatorText.startsWith('}') && !variableOperator) {
1497+ // Typing first } on a standalone variable shorthand like {{.Lila}
1498+ isTypingClosingBrace = true;
1499+ } else if (cursorOffset > macroText.length && !variableOperator) {
1500+ // Cursor is after }} on a standalone variable shorthand like {{.Lila}}|
1501+ isTypingClosingBrace = true;
1502+ } else if (cursorOffset > macroText.length && variableOperator) {
1503+ // Cursor is after }} on any operator shorthand like {{.Lila++}}| or {{.Lila+=4}}|
1504+ isTypingClosingBrace = true;
1505+ } else if (cursorOffset >= macroText.length && variableOperator && !operatorNeedsValue) {
1506+ // Cursor at end of complete operator (++ or --) like {{.Lila++ or {{.Lila++ (with trailing space)
1507+ isTypingClosingBrace = true;
1508+ } else if (cursorOffset >= macroText.length && !variableOperator && variableName.length > 0) {
1509+ // Cursor at end of standalone variable (with or without trailing whitespace) like {{.Lila or {{ .Lila
1510+ isTypingClosingBrace = true;
1511+ } else if (operatorNeedsValue && variableValue.length > 0 && variableValue.endsWith('}')) {
1512+ // Typing first } after a value like {{.Lila+=4}
1513+ isTypingClosingBrace = true;
1514+ // Strip the } from the value
1515+ variableValue = variableValue.slice(0, -1);
1516+ } else if (operatorNeedsValue && cursorOffset >= macroText.length && variableValue.length > 0) {
1517+ // Cursor at end after typing a value (including trailing whitespace) like {{.Lila+=4
1518+ // This means the shorthand is "complete" and ready to close
1519+ isTypingClosingBrace = true;
1520+ }
1521+
1522+ // Determine cursor position context for autocomplete
1523+ // Note: isTypingClosingBrace takes precedence - if we're typing a closing brace,
1524+ // we don't want to show operator suggestions, just the current state
1525+ const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1;
1526+ if (cursorOffset < prefixEnd) {
1527+ // Cursor is before the prefix - still in flags area conceptually
1528+ isTypingVariableName = false;
1529+ } else if (cursorOffset <= variableNameEnd) {
1530+ // Cursor is in the variable name area (including at the end)
1531+ isTypingVariableName = true;
1532+ } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars && !isTypingClosingBrace) {
1533+ // Cursor is after variable name but no operator yet (and no invalid chars)
1534+ // This includes partial operator prefixes like '+', '-', '|', '?', '>', '<'
1535+ // But NOT when typing a closing brace - that takes precedence
1536+ isTypingOperator = true;
1537+ } else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) {
1538+ // Short operator that could be prefix of longer one (e.g., > could become >=)
1539+ // But ONLY if cursor is still in the operator area, not past it into value
1540+ isTypingOperator = true;
1541+ } else if (operatorNeedsValue) {
1542+ // Operator that requires value - cursor is in value area
1543+ isTypingValue = true;
1544+ }
1545+ // For ++ and --, the operator is complete (no value needed)
1546+ // For invalid trailing chars, none of the typing flags will be true
1547+ const isOperatorComplete = (variableOperator === '++' || variableOperator === '--');
1548+
1549+ void onboardingExperimentalMacroEngine('variable shorthands');
1550+
1551+ // Return early for variable shorthand - different structure than regular macros
1552+ return {
1553+ fullText: macroText,
1554+ cursorOffset,
1555+ paddingBefore: macroText.match(/^\s+/)?.[0] ?? '',
1556+ identifier: '', // No macro identifier for variable shorthand
1557+ identifierStart: -1,
1558+ isInFlagsArea: false,
1559+ flags,
1560+ currentFlag,
1561+ args: [],
1562+ currentArgIndex: -1,
1563+ isTypingSeparator: false,
1564+ isTypingClosingBrace,
1565+ hasSpaceAfterIdentifier: false,
1566+ hasSpaceArgContent: false,
1567+ separatorCount: 0,
1568+ // Variable shorthand specific properties
1569+ isVariableShorthand,
1570+ variablePrefix,
1571+ variableName,
1572+ variableNameEnd,
1573+ variableOperator,
1574+ variableOperatorEnd,
1575+ variableValue,
1576+ isTypingVariableName,
1577+ isTypingOperator,
1578+ isTypingValue,
1579+ isOperatorComplete,
1580+ hasInvalidTrailingChars,
1581+ invalidTrailingChars,
1582+ partialOperator,
1583+ };
2341584 }
235- // Push the last part
1585+
236- parts.push({ text: currentPart, start: partStart, end: macroText.length });
1586+ // Regular macro parsing (not variable shorthand)
1587+ // Now parse the identifier and arguments starting from position i
1588+ const remainingText = macroText.slice(i);
1589+ const parts = [];
1590+ /** @type {{ start: number, end: number }[]} */
1591+ const separatorPositions = []; // Track positions of :: separators
1592+ let currentPart = '';
1593+ let partStart = i;
1594+ let j = 0;
1595+
1596+ // Track nesting depth to skip :: inside nested macros
1597+ let nestedDepth = 0;
1598+ // Track if we've seen a :: separator - newlines before first :: should stop parsing
1599+ let hasSeenSeparator = false;
1600+ // Track if we broke early (e.g., at a newline)
1601+ let brokeEarly = false;
1602+ while (j < remainingText.length) {
1603+ // Before the first :: separator, newlines should stop parsing
1604+ // This prevents text on the next line from being considered part of the identifier/space-arg
1605+ if (!hasSeenSeparator && nestedDepth === 0 && (remainingText[j] === '\n' || remainingText[j] === '\r')) {
1606+ // Stop parsing here - don't include the newline or anything after
1607+ brokeEarly = true;
1608+ break;
1609+ }
1610+ // Track nested macro braces
1611+ if (remainingText[j] === '{' && remainingText[j + 1] === '{') {
1612+ nestedDepth++;
1613+ currentPart += '{{';
1614+ j += 2;
1615+ continue;
1616+ }
1617+ if (remainingText[j] === '}' && remainingText[j + 1] === '}') {
1618+ nestedDepth = Math.max(0, nestedDepth - 1);
1619+ currentPart += '}}';
1620+ j += 2;
1621+ continue;
1622+ }
1623+ // Only count :: as separator when not inside nested macros
1624+ if (nestedDepth === 0 && remainingText[j] === ':' && remainingText[j + 1] === ':') {
1625+ parts.push({ text: currentPart, start: partStart, end: i + j });
1626+ separatorPositions.push({ start: i + j, end: i + j + 2 });
1627+ currentPart = '';
1628+ j += 2;
1629+ partStart = i + j;
1630+ hasSeenSeparator = true;
1631+ } else {
1632+ currentPart += remainingText[j];
1633+ j++;
1634+ }
1635+ }
1636+ // Push the last part - use correct end position if we broke early.
1637+ // If we broke early (at a newline) AND cursor is past that point, don't push -
1638+ // this filters out text on the next line from being considered part of this macro.
1639+ // But if we didn't break early (cursor at end of closed macro), always push.
1640+ const lastPartEnd = brokeEarly ? i + j : macroText.length;
1641+ const shouldPushLastPart = !brokeEarly || cursorOffset <= lastPartEnd;
1642+ if (shouldPushLastPart) {
1643+ parts.push({ text: currentPart, start: partStart, end: lastPartEnd });
1644+ }
1645+
1646+ // Determine if cursor is in the flags area (at or before identifier starts)
1647+ const identifierStartPos = parts[0]?.start ?? i;
1648+ const isInFlagsArea = cursorOffset <= identifierStartPos;
1649+
1650+ // Check if cursor is on a partial separator (single ':' that might become '::')
1651+ const isTypingSeparator = remainingText.length > 0 &&
1652+ cursorOffset > identifierStartPos &&
1653+ macroText[cursorOffset - 1] === ':' &&
1654+ macroText[cursorOffset] !== ':' &&
1655+ (cursorOffset < 2 || macroText[cursorOffset - 2] !== ':');
1656+
1657+ // Parse identifier and space-separated argument from the first part
1658+ // "getvar myvar" -> identifier="getvar", spaceArg="myvar"
1659+ // "setvar " -> identifier="setvar", spaceArg="" (just whitespace, no content yet)
1660+ const firstPartText = parts[0]?.text || '';
1661+ const trimmedFirstPart = firstPartText.trimStart();
1662+ const firstSpaceInIdentifier = trimmedFirstPart.search(/\s/);
1663+
1664+ let identifierOnly;
1665+ let spaceArgText = '';
1666+ //let spaceArgStart = -1;
1667+ let hasSpaceAfterIdentifier = false;
1668+
1669+ if (firstSpaceInIdentifier > 0 && separatorPositions.length === 0) {
1670+ // There's whitespace inside the first part - split identifier from space-arg
1671+ identifierOnly = trimmedFirstPart.slice(0, firstSpaceInIdentifier);
1672+ const afterIdentifier = trimmedFirstPart.slice(firstSpaceInIdentifier);
1673+ // Check if there's actual content after the whitespace (not just spaces or ::)
1674+ const contentAfterSpace = afterIdentifier.trimStart();
1675+ hasSpaceAfterIdentifier = afterIdentifier.length > 0; // Has at least a space
1676+
1677+ if (contentAfterSpace.length > 0 && !contentAfterSpace.startsWith(':')) {
1678+ // There's actual argument content after the space
1679+ spaceArgText = contentAfterSpace;
1680+ //spaceArgStart = identifierStartPos + firstSpaceInIdentifier + (afterIdentifier.length - contentAfterSpace.length);
1681+ }
1682+ } else {
1683+ identifierOnly = trimmedFirstPart.trimEnd();
1684+ }
1685+
1686+ // Calculate identifier end position (for space-after-identifier detection)
1687+ const identifierEndPos = identifierStartPos + (firstPartText.length - firstPartText.trimStart().length) + identifierOnly.length;
2371688
2381689 // Determine which part the cursor is in
2391690 let currentArgIndex = -1;
240- for (let idx = 0; idx < parts.length; idx++) {
1691+
241- const part = parts[idx];
1692+ // Only consider being in an argument if we've passed a separator
242- if (cursorOffset >= part.start && cursorOffset <= part.end) {
1693+ if (separatorPositions.length > 0) {
243- currentArgIndex = idx - 1; // -1 because first part is identifier
1694+ // Find which argument we're in based on separator positions
244- break;
1695+ for (let sepIdx = 0; sepIdx < separatorPositions.length; sepIdx++) {
1696+ const sep = separatorPositions[sepIdx];
1697+ if (cursorOffset >= sep.end) {
1698+ // We're past this separator, so we're in at least this argument
1699+ currentArgIndex = sepIdx;
1700+ }
2451701 }
1702+ } else if (spaceArgText.length > 0 || (hasSpaceAfterIdentifier && cursorOffset > identifierEndPos)) {
1703+ // Space-separated arg: either has content, or cursor is past identifier+space
1704+ currentArgIndex = 0;
1705+ }
1706+
1707+ // If typing a separator, we're still on identifier/previous arg, not the next one
1708+ if (isTypingSeparator) {
1709+ currentArgIndex = -1;
1710+ }
1711+
1712+ const leftPadding = macroText.match(/^\s+/)?.[0] ?? '';
1713+
1714+ if (leftPadding) {
1715+ void onboardingExperimentalMacroEngine('leading whitespace');
1716+ }
1717+
1718+ // Clean identifier: strip trailing colons (for partial :: typing)
1719+ // Also strip trailing single } (for partial }} typing) - but only if no separators/args
1720+ let cleanIdentifier = identifierOnly.replace(/:+$/, '');
1721+ let isTypingClosingBrace = false;
1722+ if (separatorPositions.length === 0 && !hasSpaceAfterIdentifier && cleanIdentifier.endsWith('}')) {
1723+ // Typing first closing brace on a standalone macro like {{char}
1724+ cleanIdentifier = cleanIdentifier.slice(0, -1);
1725+ isTypingClosingBrace = true;
2461726 }
2471727
248- // If cursor is after all parts (at the end), we're in the last arg
1728+ // Build args array - include space-separated arg if present
249- if (currentArgIndex === -1 && cursorOffset >= parts[parts.length - 1].end) {
1729+ // Trim args like the macro engine does
250- currentArgIndex = parts.length - 1;
1730+ let args = parts.slice(1).map(p => p.text.trim());
1731+ if (spaceArgText.length > 0) {
1732+ args = [spaceArgText, ...args];
2511733 }
2521734
2531735 return {
2541736 fullText: macroText,
2551737 cursorOffset,
256- identifier: parts[0]?.text.trim() || '',
1738+ paddingBefore: leftPadding,
257- args: parts.slice(1).map(p => p.text),
1739+ identifier: cleanIdentifier,
1740+ identifierStart: identifierStartPos,
1741+ isInFlagsArea,
1742+ flags,
1743+ currentFlag,
1744+ args,
2581745 currentArgIndex,
1746+ isTypingSeparator,
1747+ isTypingClosingBrace,
1748+ hasSpaceAfterIdentifier,
1749+ hasSpaceArgContent: spaceArgText.length > 0,
1750+ separatorCount: separatorPositions.length,
1751+ // Default variable shorthand properties (not a variable shorthand)
1752+ isVariableShorthand: false,
1753+ variablePrefix: null,
1754+ variableName: '',
1755+ variableNameEnd: null,
1756+ variableOperator: null,
1757+ variableOperatorEnd: null,
1758+ variableValue: '',
1759+ isTypingVariableName: false,
1760+ isTypingOperator: false,
1761+ isTypingValue: false,
2591762 };
2601763}
1764+
1765+/**
1766+ * A simple, generic autocomplete option for displaying basic items with name, symbol, and description.
1767+ * Useful for simple options like inversion markers, prefixes, etc. without needing a full custom class.
1768+ *
1769+ * @extends AutoCompleteOption
1770+ */
1771+export class SimpleAutoCompleteOption extends AutoCompleteOption {
1772+ /** @type {string} */
1773+ #description;
1774+
1775+ /** @type {string|null} */
1776+ #detailedDescription;
1777+
1778+ /**
1779+ * @param {Object} config - Configuration for the option.
1780+ * @param {string} config.name - The option name/key (used for matching).
1781+ * @param {string} [config.symbol=' '] - Icon/symbol shown in the type column.
1782+ * @param {string} [config.description=''] - Short description shown inline.
1783+ * @param {string} [config.detailedDescription] - Longer description for details panel (supports HTML). Falls back to description if not provided.
1784+ * @param {string} [config.type='simple'] - Type identifier for CSS/data attributes.
1785+ */
1786+ constructor({ name, symbol = ' ', description = '', detailedDescription = null, type = 'simple' }) {
1787+ super(name, symbol, type);
1788+ this.#description = description;
1789+ this.#detailedDescription = detailedDescription;
1790+ }
1791+
1792+ /** @returns {string} */
1793+ get description() {
1794+ return this.#description;
1795+ }
1796+
1797+ /** @returns {string} */
1798+ get detailedDescription() {
1799+ return this.#detailedDescription ?? this.#description;
1800+ }
1801+
1802+ /**
1803+ * @returns {HTMLElement}
1804+ */
1805+ renderItem() {
1806+ const li = document.createElement('li');
1807+ li.classList.add('item');
1808+ li.setAttribute('data-name', this.name);
1809+ li.setAttribute('data-option-type', this.type);
1810+
1811+ // Type icon
1812+ const typeSpan = document.createElement('span');
1813+ typeSpan.classList.add('type', 'monospace');
1814+ typeSpan.textContent = this.typeIcon;
1815+ li.append(typeSpan);
1816+
1817+ // Name
1818+ const specs = document.createElement('span');
1819+ specs.classList.add('specs');
1820+ const nameSpan = document.createElement('span');
1821+ nameSpan.classList.add('name', 'monospace');
1822+ this.name.split('').forEach(char => {
1823+ const span = document.createElement('span');
1824+ span.textContent = char;
1825+ nameSpan.append(span);
1826+ });
1827+ specs.append(nameSpan);
1828+ li.append(specs);
1829+
1830+ // Stopgap
1831+ const stopgap = document.createElement('span');
1832+ stopgap.classList.add('stopgap');
1833+ li.append(stopgap);
1834+
1835+ // Help/description
1836+ const help = document.createElement('span');
1837+ help.classList.add('help');
1838+ const content = document.createElement('span');
1839+ content.classList.add('helpContent');
1840+ content.textContent = this.#description;
1841+ help.append(content);
1842+ li.append(help);
1843+
1844+ return li;
1845+ }
1846+
1847+ /**
1848+ * @returns {DocumentFragment}
1849+ */
1850+ renderDetails() {
1851+ const frag = document.createDocumentFragment();
1852+
1853+ // Header with name
1854+ const specs = document.createElement('div');
1855+ specs.classList.add('specs');
1856+ const nameDiv = document.createElement('div');
1857+ nameDiv.classList.add('name', 'monospace');
1858+ nameDiv.textContent = this.name;
1859+ specs.append(nameDiv);
1860+ frag.append(specs);
1861+
1862+ // Description
1863+ if (this.detailedDescription) {
1864+ const helpDiv = document.createElement('div');
1865+ helpDiv.classList.add('help');
1866+ helpDiv.innerHTML = this.detailedDescription;
1867+ frag.append(helpDiv);
1868+ }
1869+
1870+ return frag;
1871+ }
1872+}
public/scripts/autocomplete/MacroAutoComplete.js+307 -0
@@ -0,0 +1,307 @@
1+/**
2+ * Macro autocomplete for free text inputs (textareas and input fields).
3+ * Provides macro autocomplete when typing `{{` in marked text inputs.
4+ *
5+ * This module uses shared utilities from MacroAutoCompleteHelper.js to ensure
6+ * consistent behavior with the slash command macro autocomplete.
7+ *
8+ * Usage:
9+ * - Mark a textarea/input with `data-macros` or `data-macros="true"` attribute
10+ * - Call `initMacroAutoComplete()` to initialize all marked elements
11+ * - Dynamically added elements are automatically initialized via MutationObserver
12+ */
13+
14+import { power_user } from '../power-user.js';
15+import { AutoComplete, AUTOCOMPLETE_STATE } from './AutoComplete.js';
16+import { findMacroAtCursor, findUnclosedScopes, getMacroAutoCompleteAt } from './MacroAutoCompleteHelper.js';
17+
18+/** Custom attribute name used to mark elements that support macro autocomplete */
19+export const MACRO_AUTOCOMPLETE_ATTRIBUTE = 'data-macros';
20+
21+/** Attribute to control autocomplete visibility: 'always' (force show) or 'hide' (never show) */
22+export const MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE = 'data-macros-autocomplete';
23+
24+/** Generic attribute to control autocomplete popup style/size (used by AutoComplete) */
25+export const MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE = 'data-macros-autocomplete-style';
26+
27+/**
28+ * @readonly
29+ * @enum {string}
30+ */
31+export const MACRO_AUTOCOMPLETE_MODE = Object.freeze({
32+ /** Default behavior: respects global setting showInAllMacroFields */
33+ DEFAULT: 'default',
34+ /** Always show autocomplete in this field (expanded editors, prompt manager) */
35+ ALWAYS: 'always',
36+ /** Never show autocomplete in this field */
37+ HIDE: 'hide',
38+});
39+
40+/**
41+ * @readonly
42+ * @enum {string}
43+ */
44+export const MACRO_AUTOCOMPLETE_STYLE = Object.freeze({
45+ /** Small popup (33vw, max 700px) for inline fields */
46+ SMALL: 'small',
47+ /** Expanded popup (default chat width) for expanded editors */
48+ EXPANDED: 'expanded',
49+});
50+
51+/** @type {WeakSet<HTMLElement>} Track initialized elements to avoid double-init */
52+const initializedElements = new WeakSet();
53+
54+/** @type {WeakMap<HTMLElement, AutoComplete>} Map elements to their autocomplete instances */
55+const elementAutoCompleteMap = new WeakMap();
56+
57+/**
58+ * Checks if the cursor is positioned where macro autocomplete should activate.
59+ * Activates when:
60+ * - Cursor is right after typing `{{`
61+ * - Cursor is inside a macro `{{...}}`
62+ * - Cursor is in scoped content of an unclosed scoped macro (e.g., after `{{setvar myvar}}`)
63+ *
64+ * @param {string} text - The full text content.
65+ * @param {number} cursorPos - The cursor position.
66+ * @param {Object} [options={}] - Additional options.
67+ * @param {boolean} [options.isForced=false] - Whether this is a forced activation (e.g., Ctrl+Space).
68+ * @param {MACRO_AUTOCOMPLETE_MODE} [options.autocompleteMode=MACRO_AUTOCOMPLETE_MODE.DEFAULT] - The autocomplete mode.
69+ * @returns {boolean}
70+ */
71+function shouldActivateMacroAutocomplete(text, cursorPos, { isForced = false, autocompleteMode = MACRO_AUTOCOMPLETE_MODE.DEFAULT } = {}) {
72+ // If mode is 'hide', never show autocomplete
73+ if (autocompleteMode === MACRO_AUTOCOMPLETE_MODE.HIDE) {
74+ return false;
75+ }
76+
77+ // Check if autocomplete is enabled at all
78+ if (power_user.stscript.autocomplete.state === AUTOCOMPLETE_STATE.DISABLED) {
79+ return false;
80+ }
81+
82+ // Determine if we should show normally based on mode and settings
83+ // ALWAYS mode: always show, DEFAULT mode: respect global setting
84+ const alwaysShow = autocompleteMode === MACRO_AUTOCOMPLETE_MODE.ALWAYS;
85+ const shouldShowNormally = isForced || alwaysShow || power_user.stscript.autocomplete.showInAllMacroFields;
86+
87+ // Whether setting says autocomplete should only activate after typing {{ and two characters after that
88+ // Ctrl+Space (isForced) overrides this restriction
89+ const onlyAfter2 = !isForced && power_user.stscript.autocomplete.state === AUTOCOMPLETE_STATE.MIN_LENGTH;
90+
91+ // Check if we're right after {{ (just typed the second brace)
92+ if (cursorPos >= 2 && text.slice(cursorPos - 2, cursorPos) === '{{') {
93+ return shouldShowNormally && !onlyAfter2;
94+ }
95+
96+ // Check if we're inside a macro
97+ const macro = findMacroAtCursor(text, cursorPos);
98+ if (macro !== null) {
99+ if (!shouldShowNormally) return false;
100+ return !onlyAfter2 || (macro.content.trim()).length >= 2;
101+ }
102+
103+ // Check if we're in scoped content of an unclosed scoped macro
104+ const textUpToCursor = text.slice(0, cursorPos);
105+ const unclosedScopes = findUnclosedScopes(textUpToCursor);
106+ return shouldShowNormally && unclosedScopes.length > 0;
107+}
108+
109+/**
110+ * Sets up macro autocomplete for a text input element.
111+ * The autocomplete will trigger when typing `{{` inside the element.
112+ *
113+ * @param {HTMLTextAreaElement|HTMLInputElement} textarea - The input element.
114+ * @param {Object} [options={}] - Options for the autocomplete.
115+ * @param {MACRO_AUTOCOMPLETE_MODE} [options.autocompleteMode=MACRO_AUTOCOMPLETE_MODE.DEFAULT] - The autocomplete mode.
116+ * @param {MACRO_AUTOCOMPLETE_STYLE} [options.autocompleteStyle=MACRO_AUTOCOMPLETE_STYLE.SMALL] - The autocomplete style.
117+ * @returns {AutoComplete} The autocomplete instance.
118+ */
119+export function setMacroAutoComplete(textarea, { autocompleteMode = MACRO_AUTOCOMPLETE_MODE.DEFAULT, autocompleteStyle = MACRO_AUTOCOMPLETE_STYLE.SMALL } = {}) {
120+ const ac = new AutoComplete(
121+ textarea,
122+ () => shouldActivateMacroAutocomplete(ac.text, textarea.selectionStart, { isForced: ac.isShowForced, autocompleteMode }),
123+ (text, index) => getMacroAutoCompleteAt(text, index, { isForced: ac.isShowForced }),
124+ true, // isFloating - always use floating mode for free text macro autocomplete
125+ );
126+
127+ // Set the style via data attribute for CSS targeting
128+ ac.domWrap.dataset.macrosAutocompleteStyle = autocompleteStyle;
129+ ac.detailsWrap.dataset.macrosAutocompleteStyle = autocompleteStyle;
130+
131+ elementAutoCompleteMap.set(textarea, ac);
132+ return ac;
133+}
134+
135+/**
136+ * Gets the autocomplete mode from an element's data-macros-autocomplete attribute.
137+ *
138+ * @param {Element} element - The element to check.
139+ * @returns {MACRO_AUTOCOMPLETE_MODE} The mode ('default', 'always', 'hide').
140+ */
141+function getAutocompleteMode(element) {
142+ if (!element.hasAttribute(MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE)) {
143+ return MACRO_AUTOCOMPLETE_MODE.DEFAULT;
144+ }
145+ const value = element.getAttribute(MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE);
146+ if (value === MACRO_AUTOCOMPLETE_MODE.ALWAYS || value === MACRO_AUTOCOMPLETE_MODE.HIDE) {
147+ return value;
148+ }
149+ return MACRO_AUTOCOMPLETE_MODE.DEFAULT;
150+}
151+
152+/**
153+ * Gets the autocomplete style from an element's data-autocomplete-style attribute.
154+ *
155+ * @param {Element} element - The element to check.
156+ * @returns {MACRO_AUTOCOMPLETE_STYLE} The style ('expanded', 'small').
157+ */
158+function getAutocompleteStyle(element) {
159+ if (!element.hasAttribute(MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE)) {
160+ return MACRO_AUTOCOMPLETE_STYLE.SMALL; // Default for macro autocomplete is small
161+ }
162+ const value = element.getAttribute(MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE);
163+ if (value === MACRO_AUTOCOMPLETE_STYLE.SMALL || value === MACRO_AUTOCOMPLETE_STYLE.EXPANDED) {
164+ return value;
165+ }
166+ return MACRO_AUTOCOMPLETE_STYLE.EXPANDED;
167+}
168+
169+/**
170+ * Initializes macro autocomplete on a single element if not already initialized.
171+ *
172+ * @param {HTMLTextAreaElement|HTMLInputElement} element - The element to initialize.
173+ * @returns {AutoComplete|null} The autocomplete instance, or null if already initialized.
174+ */
175+function initializeElement(element) {
176+ if (initializedElements.has(element)) {
177+ return null;
178+ }
179+
180+ if (!(element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement)) {
181+ return null;
182+ }
183+
184+ const autocompleteMode = getAutocompleteMode(element);
185+ const autocompleteStyle = getAutocompleteStyle(element);
186+ initializedElements.add(element);
187+ return setMacroAutoComplete(element, { autocompleteMode, autocompleteStyle });
188+}
189+
190+/**
191+ * Checks if an element has the macro autocomplete attribute enabled.
192+ * Supports both `data-macros` (presence) and `data-macros="true"`.
193+ *
194+ * @param {Element} element - The element to check.
195+ * @returns {boolean}
196+ */
197+function hasMacroAttribute(element) {
198+ if (!element.hasAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE)) {
199+ return false;
200+ }
201+ const value = element.getAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE);
202+ // Attribute present with no value, empty string, or "true" all count as enabled
203+ return value === null || value === '' || value === 'true';
204+}
205+
206+/**
207+ * Handles node changes from MutationObserver - checks for macro autocomplete attribute.
208+ *
209+ * @param {Node} node - The node to check.
210+ */
211+function handleNodeChange(node) {
212+ if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof Element)) {
213+ return;
214+ }
215+
216+ // Check if this element has the macro autocomplete attribute
217+ if (hasMacroAttribute(node)) {
218+ if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
219+ initializeElement(node);
220+ }
221+ }
222+
223+ // Check child elements - select all elements with the attribute (any value or no value)
224+ const children = node.querySelectorAll(`[${MACRO_AUTOCOMPLETE_ATTRIBUTE}]`);
225+ for (const child of children) {
226+ if (hasMacroAttribute(child) && (child instanceof HTMLTextAreaElement || child instanceof HTMLInputElement)) {
227+ initializeElement(child);
228+ }
229+ }
230+}
231+
232+/**
233+ * MutationObserver to watch for dynamically added elements with macro autocomplete attribute.
234+ * @type {MutationObserver}
235+ */
236+const observer = new MutationObserver(mutations => {
237+ for (const mutation of mutations) {
238+ if (mutation.type === 'childList') {
239+ for (const node of mutation.addedNodes) {
240+ handleNodeChange(node);
241+ }
242+ }
243+ if (mutation.type === 'attributes') {
244+ const target = mutation.target;
245+ const isRelevantAttr = mutation.attributeName === MACRO_AUTOCOMPLETE_ATTRIBUTE ||
246+ mutation.attributeName === MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE ||
247+ mutation.attributeName === MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE;
248+ if (isRelevantAttr && target instanceof Element) {
249+ handleNodeChange(target);
250+ }
251+ }
252+ }
253+});
254+
255+/**
256+ * Initializes macro autocomplete for all elements with the `data-macros` attribute.
257+ * Also starts the MutationObserver to watch for dynamically added elements.
258+ * Should be called after DOM is ready.
259+ *
260+ * @returns {AutoComplete[]} Array of autocomplete instances created.
261+ */
262+export function initMacroAutoComplete() {
263+ const elements = /** @type {NodeListOf<HTMLTextAreaElement|HTMLInputElement>} */ (
264+ document.querySelectorAll(`[${MACRO_AUTOCOMPLETE_ATTRIBUTE}]`)
265+ );
266+
267+ const instances = [];
268+ for (const element of elements) {
269+ if (hasMacroAttribute(element)) {
270+ const ac = initializeElement(element);
271+ if (ac) {
272+ instances.push(ac);
273+ }
274+ }
275+ }
276+
277+ // Start observing for dynamically added elements
278+ observer.observe(document.body, {
279+ childList: true,
280+ subtree: true,
281+ attributes: true,
282+ attributeFilter: [MACRO_AUTOCOMPLETE_ATTRIBUTE, MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE, MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE],
283+ });
284+
285+ return instances;
286+}
287+
288+/**
289+ * Enables macro autocomplete on a specific element by ID.
290+ * Adds the attribute and initializes autocomplete.
291+ *
292+ * @param {string} elementId - The element ID (without #).
293+ * @returns {AutoComplete|null} The autocomplete instance, or null if element not found.
294+ */
295+export function enableMacroAutoCompleteById(elementId) {
296+ const element = /** @type {HTMLTextAreaElement|HTMLInputElement|null} */ (
297+ document.getElementById(elementId)
298+ );
299+
300+ if (!element || !(element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement)) {
301+ console.warn(`[MacroAutoComplete] Element not found or invalid: ${elementId}`);
302+ return null;
303+ }
304+
305+ element.setAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE, 'true');
306+ return initializeElement(element);
307+}
public/scripts/autocomplete/MacroAutoCompleteHelper.js+1217 -0
@@ -0,0 +1,1217 @@
1+/**
2+ * Shared utilities for macro autocomplete functionality.
3+ * Used by both SlashCommandParser (for slash command context) and MacroAutoComplete (for free text).
4+ *
5+ * This module extracts common macro autocomplete logic to avoid duplication and ensure
6+ * consistent behavior across all contexts where macro autocomplete is used.
7+ */
8+
9+import { AutoCompleteNameResult } from './AutoCompleteNameResult.js';
10+import {
11+ EnhancedMacroAutoCompleteOption,
12+ MacroFlagAutoCompleteOption,
13+ MacroClosingTagAutoCompleteOption,
14+ VariableShorthandAutoCompleteOption,
15+ VariableShorthandDefinitions,
16+ VariableNameAutoCompleteOption,
17+ VariableOperatorAutoCompleteOption,
18+ VariableValueContextAutoCompleteOption,
19+ VariableOperatorDefinitions,
20+ isValidVariableShorthandName,
21+ parseMacroContext,
22+ SimpleAutoCompleteOption,
23+} from './EnhancedMacroAutoCompleteOption.js';
24+import { macros as macroSystem } from '../macros/macro-system.js';
25+import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
26+import { MacroParser } from '../macros/engine/MacroParser.js';
27+import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
28+import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
29+import { chat_metadata } from '/script.js';
30+import { extension_settings } from '../extensions.js';
31+
32+/** @typedef {import('./EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} MacroAutoCompleteContext */
33+/** @typedef {import('./EnhancedMacroAutoCompleteOption.js').EnhancedMacroAutoCompleteOptions} EnhancedMacroAutoCompleteOptions */
34+/** @typedef {import('./AutoCompleteOption.js').AutoCompleteOption} AutoCompleteOption */
35+/*** @typedef {import('../macros/macro-system.js').MacroDefinition} MacroDefinition */
36+
37+/**
38+ * @typedef {Object} MacroInfo
39+ * @property {number} start - Start position of the macro in text (at first {)
40+ * @property {number} end - End position of the macro in text (after last })
41+ * @property {string} content - The content between {{ and }}
42+ */
43+
44+/**
45+ * @typedef {Object} UnclosedScope
46+ * @property {string} name - Macro name
47+ * @property {number} startOffset - Start position in text
48+ * @property {number} endOffset - End position of opening tag
49+ * @property {string} paddingBefore - Whitespace before macro name
50+ * @property {string} paddingAfter - Whitespace after macro content
51+ */
52+
53+/**
54+ * @typedef {Object} BuildMacroAutoCompleteOptions
55+ * @property {MacroInfo|null} [macro=null] - Macro info if cursor is inside a macro
56+ * @property {string|null} [textUpToCursor=null] - Pre-computed text up to cursor
57+ * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes
58+ * @property {boolean} [isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space)
59+ */
60+
61+/** @typedef {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption|SimpleAutoCompleteOption)} AnyMacroAutoCompleteOption */
62+
63+/**
64+ * Finds unclosed scoped macros in the text up to cursor position.
65+ * Uses the MacroParser and MacroCstWalker for accurate analysis.
66+ *
67+ * @param {string} textUpToCursor - The document text up to the cursor position.
68+ * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
69+ */
70+export function findUnclosedScopes(textUpToCursor) {
71+ if (!textUpToCursor) return [];
72+
73+ try {
74+ // Parse the document to get the CST
75+ const { cst } = MacroParser.parseDocument(textUpToCursor);
76+ if (!cst) return [];
77+
78+ // Use the CST walker to find unclosed scopes
79+ return MacroCstWalker.findUnclosedScopes({ text: textUpToCursor, cst });
80+ } catch {
81+ // If parsing fails (incomplete input), fall back to simple regex approach
82+ return findUnclosedScopesRegex(textUpToCursor);
83+ }
84+}
85+
86+/**
87+ * Fallback regex-based approach for finding unclosed scopes.
88+ * Used when the parser fails on incomplete input.
89+ *
90+ * @param {string} text - The text to analyze.
91+ * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
92+ */
93+export function findUnclosedScopesRegex(text) {
94+ // Regex to find macro openings and closings, capturing whitespace padding
95+ // Group 1: padding after {{, Group 2: optional /, Group 3: macro name
96+ const macroPattern = /\{\{(\s*)(\/?)([\w-]+)/g;
97+ const stack = [];
98+
99+ let match;
100+ while ((match = macroPattern.exec(text)) !== null) {
101+ const paddingBefore = match[1];
102+ const isClosing = match[2] === '/';
103+ const name = match[3];
104+
105+ if (isClosing) {
106+ // Find matching opener in stack (case-insensitive)
107+ // When closing an outer scope, all inner unclosed scopes are implicitly closed
108+ const matchIndex = stack.findLastIndex(s => s.name.toLowerCase() === name.toLowerCase());
109+ if (matchIndex !== -1) {
110+ // Pop everything from matchIndex to end (inclusive) - closes the matched scope and all nested ones
111+ stack.splice(matchIndex);
112+ }
113+ } else {
114+ // Check if macro can accept scoped content
115+ // List-arg macros don't support scopes - they accept arbitrary inline args instead
116+ const macroDef = macroSystem.registry.getPrimaryMacro(name);
117+ if (macroDef && macroDef.maxArgs > 0 && macroDef.list === null) {
118+ // Try to find closing }} to extract trailing whitespace
119+ let paddingAfter = '';
120+ const afterMatch = text.slice(match.index + match[0].length);
121+ const closingMatch = afterMatch.match(/^[^}]*?(\s*)\}\}/);
122+ if (closingMatch) {
123+ paddingAfter = closingMatch[1];
124+ }
125+
126+ stack.push({
127+ name,
128+ startOffset: match.index,
129+ endOffset: match.index + match[0].length,
130+ paddingBefore,
131+ paddingAfter,
132+ });
133+ }
134+ }
135+ }
136+
137+ return stack;
138+}
139+
140+/**
141+ * Checks if a scoped macro's scope content is optional (i.e., all required args are already filled).
142+ * Used to determine whether to show the scope hint by default or only when forced.
143+ *
144+ * @param {UnclosedScope} scope - The unclosed scope info.
145+ * @param {string} textUpToCursor - The text up to cursor to parse the macro content.
146+ * @returns {boolean} - True if the scope content is optional.
147+ */
148+function isScopeOptional(scope, textUpToCursor) {
149+ const def = macroSystem.registry.getPrimaryMacro(scope.name);
150+ if (!def) {
151+ // Unknown macro - treat scope as required (show hint)
152+ return false;
153+ }
154+
155+ // Find the macro's closing }} to extract its content
156+ const openingEnd = textUpToCursor.indexOf('}}', scope.startOffset);
157+ if (openingEnd === -1) {
158+ // Macro not closed yet - can't determine
159+ return false;
160+ }
161+
162+ // Extract content between {{ and }} to count arguments
163+ const macroContent = textUpToCursor.slice(scope.startOffset + 2, openingEnd);
164+ const context = parseMacroContext(macroContent, macroContent.length);
165+
166+ // Count current arguments (including space-separated arg if present)
167+ const currentArgCount = context.args.length;
168+
169+ // The scoped content would be the next argument (currentArgCount + 1)
170+ // Scope is optional if:
171+ // 1. Current args already meet minArgs requirement, AND
172+ // 2. Adding one more (scope) would still be <= maxArgs
173+ const wouldBeArgIndex = currentArgCount; // 0-indexed
174+ const scopeIsOptional = currentArgCount >= def.minArgs && wouldBeArgIndex < def.maxArgs;
175+
176+ // Check if the argument at wouldBeArgIndex is marked as optional in the definition
177+ if (def.unnamedArgDefs && def.unnamedArgDefs[wouldBeArgIndex]) {
178+ return def.unnamedArgDefs[wouldBeArgIndex].optional === true;
179+ }
180+
181+ // If no explicit arg definition, use the min/max args logic
182+ return scopeIsOptional;
183+}
184+
185+/**
186+ * Filters unclosed scopes to exclude those with optional scope content.
187+ * Used when autocomplete is not force-triggered (Ctrl+Space).
188+ *
189+ * @param {UnclosedScope[]} unclosedScopes - The unclosed scopes to filter.
190+ * @param {string} textUpToCursor - The text up to cursor.
191+ * @param {boolean} isForced - Whether autocomplete was force-triggered.
192+ * @returns {UnclosedScope[]} - Filtered scopes (excludes optional scopes unless forced).
193+ */
194+function filterOptionalScopes(unclosedScopes, textUpToCursor, isForced) {
195+ if (isForced) {
196+ // When forced, show all scopes including optional ones
197+ return unclosedScopes;
198+ }
199+
200+ // Filter out scopes where the scope content is optional
201+ return unclosedScopes.filter(scope => !isScopeOptional(scope, textUpToCursor));
202+}
203+
204+/**
205+ * Builds autocomplete options for variable shorthand syntax (.varName or $varName).
206+ * @param {MacroAutoCompleteContext} context
207+ * @param {Object} [opts] - Optional configuration.
208+ * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}).
209+ * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}.
210+ * @returns {AnyMacroAutoCompleteOption[]}
211+ */
212+export function buildVariableShorthandOptions(context, opts = {}) {
213+ const { forIfCondition = false, paddingAfter = '' } = opts;
214+ /** @type {AnyMacroAutoCompleteOption[]} */
215+ const options = [];
216+
217+ const isLocal = context.variablePrefix === '.';
218+ const scope = isLocal ? 'local' : 'global';
219+
220+
221+ // Always show the typed variable prefix as a non-completable option (like flags do)
222+ // This allows the details panel to show information about the prefix
223+ const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix);
224+ if (prefixDef) {
225+ const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
226+ prefixOption.valueProvider = () => ''; // Already typed, don't re-insert
227+ prefixOption.makeSelectable = false;
228+ prefixOption.sortPriority = 1; // Show at top
229+ prefixOption.matchProvider = () => true; // Always show regardless of filtering
230+ options.push(prefixOption);
231+ }
232+
233+ // If typing the variable name, suggest existing variables
234+ // Get existing variable names from the appropriate scope
235+ // Filter to only include names that are valid for shorthand syntax
236+ const existingVariables = getVariableNames(scope)
237+ .filter(name => isValidVariableShorthandName(name));
238+
239+ // Check if the typed variable name exactly matches an existing variable
240+ const variableNameMatchesExisting = context.variableName.length > 0 && existingVariables.includes(context.variableName);
241+
242+ if (context.isTypingVariableName) {
243+ // Add existing variables that match the typed name
244+ for (const varName of existingVariables) {
245+ const option = new VariableNameAutoCompleteOption(varName, scope, false);
246+ // Not selectable if it matches the typed name
247+ if (varName === context.variableName) {
248+ option.valueProvider = () => '';
249+ option.makeSelectable = false;
250+ }
251+ // For {{if}} condition, provide full value with closing braces
252+ if (forIfCondition) {
253+ option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already.
254+ option.makeSelectable = true;
255+ }
256+ // Variables matching the typed prefix get higher priority
257+ option.sortPriority = varName.startsWith(context.variableName) ? 3 : 10;
258+ options.push(option);
259+ }
260+
261+ // If typing a name that doesn't exist, offer to create a new variable
262+ // But if the name is invalid for shorthand syntax, show a warning instead
263+ if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) {
264+ const isInvalid = !isValidVariableShorthandName(context.variableName);
265+ const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid);
266+ newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning
267+ if (isInvalid) {
268+ // Make it non-selectable since it can't be used
269+ newVarOption.valueProvider = () => '';
270+ newVarOption.makeSelectable = false;
271+ } else if (forIfCondition) {
272+ // For {{if}} condition, provide full value with closing braces
273+ newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`;
274+ newVarOption.makeSelectable = true;
275+ }
276+ options.push(newVarOption);
277+ }
278+
279+ // If the typed variable name exactly matches an existing variable, also show operators
280+ // This allows users to see available operators without having to type a space first
281+ if (variableNameMatchesExisting) {
282+ for (const [, operatorDef] of VariableOperatorDefinitions) {
283+ const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
284+ opOption.sortPriority = 6; // Lower priority than variable suggestions
285+ opOption.matchProvider = () => true; // Always show
286+ // IMPORTANT: Operators should INSERT after variable name, not replace it
287+ // Use replacementStartOffset to shift insertion point past the variable name
288+ opOption.replacementStartOffset = context.variableName.length;
289+ options.push(opOption);
290+ }
291+ }
292+ }
293+
294+ // If there are invalid trailing characters after the variable name, show a warning
295+ if (context.hasInvalidTrailingChars) {
296+ // Show the full invalid name (variableName + invalidTrailingChars) with a warning
297+ const fullInvalidName = context.variableName + (context.invalidTrailingChars || '');
298+ const invalidOption = new VariableNameAutoCompleteOption(
299+ fullInvalidName,
300+ scope,
301+ false,
302+ true, // isInvalidName - triggers warning display
303+ );
304+ invalidOption.valueProvider = () => ''; // Don't insert anything
305+ invalidOption.makeSelectable = false;
306+ invalidOption.sortPriority = 2;
307+ invalidOption.matchProvider = () => true; // Always show
308+ options.push(invalidOption);
309+ // Return early - don't show operators when syntax is invalid
310+ return options;
311+ }
312+
313+ // If ready for operator (after variable name), suggest operators
314+ if (context.isTypingOperator) {
315+ // Show the current variable name as context (already typed)
316+ const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
317+ varNameOption.valueProvider = () => ''; // Already typed, don't re-insert
318+ varNameOption.makeSelectable = false;
319+ varNameOption.sortPriority = 2;
320+ varNameOption.matchProvider = () => true; // Always show
321+ options.push(varNameOption);
322+
323+ // Then show available operators, filtered by partial prefix if any
324+ // Also filter by current complete operator to show longer variants (e.g., > shows >=)
325+ const partialOp = context.partialOperator || '';
326+ const currentOp = context.variableOperator || '';
327+ const filterPrefix = partialOp || currentOp;
328+ for (const [, operatorDef] of VariableOperatorDefinitions) {
329+ // Filter by operator prefix if user is typing one
330+ // This allows typing ">" to show both ">" and ">="
331+ if (filterPrefix && !operatorDef.symbol.startsWith(filterPrefix)) {
332+ continue;
333+ }
334+ const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
335+ // Exact match gets higher priority
336+ opOption.sortPriority = operatorDef.symbol === currentOp ? 4 : 5;
337+ // Already-typed operator is non-selectable
338+ if (operatorDef.symbol === currentOp) {
339+ opOption.valueProvider = () => '';
340+ opOption.makeSelectable = false;
341+ }
342+ // Always match operators when showing operator suggestions
343+ opOption.matchProvider = () => true;
344+ options.push(opOption);
345+ }
346+ }
347+
348+ // If typing value (after = or +=), no autocomplete needed - freeform text
349+ // But we show the current context for reference (greyed out, non-selectable)
350+ if (context.isTypingValue && !context.isTypingOperator && !context.isTypingClosingBrace) {
351+ // Show the current variable name as context (non-selectable)
352+ const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
353+ varNameOption.valueProvider = () => ''; // Context only
354+ varNameOption.makeSelectable = false;
355+ varNameOption.sortPriority = 2;
356+ varNameOption.matchProvider = () => true; // Always show
357+ options.push(varNameOption);
358+
359+ // Show the operator that was used (non-selectable)
360+ if (context.variableOperator) {
361+ const opDef = VariableOperatorDefinitions.get(context.variableOperator);
362+ if (opDef) {
363+ const opOption = new VariableOperatorAutoCompleteOption(opDef);
364+ opOption.valueProvider = () => ''; // Already typed
365+ opOption.makeSelectable = false;
366+ opOption.sortPriority = 3;
367+ opOption.matchProvider = () => true; // Always show
368+ options.push(opOption);
369+
370+ // Show value context info (non-selectable)
371+ const valueOption = new VariableValueContextAutoCompleteOption(opDef, context.variableValue);
372+ valueOption.valueProvider = () => ''; // Context only
373+ valueOption.makeSelectable = false;
374+ valueOption.sortPriority = 4;
375+ valueOption.matchProvider = () => true; // Always show
376+ options.push(valueOption);
377+ }
378+ }
379+ }
380+
381+ // If operator is complete (++ or --), show context without value input (non-selectable)
382+ if (context.isOperatorComplete && !context.isTypingOperator) {
383+ // Show the current variable name as context (non-selectable)
384+ const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
385+ varNameOption.valueProvider = () => ''; // Context only
386+ varNameOption.makeSelectable = false;
387+ varNameOption.sortPriority = 2;
388+ varNameOption.matchProvider = () => true; // Always show
389+ options.push(varNameOption);
390+
391+ // Show the operator that was used (non-selectable)
392+ if (context.variableOperator) {
393+ const opDef = VariableOperatorDefinitions.get(context.variableOperator);
394+ if (opDef) {
395+ const opOption = new VariableOperatorAutoCompleteOption(opDef);
396+ opOption.valueProvider = () => ''; // Already typed
397+ opOption.makeSelectable = false;
398+ opOption.sortPriority = 3;
399+ opOption.matchProvider = () => true; // Always show
400+ options.push(opOption);
401+ }
402+ }
403+ }
404+
405+ // If typing closing brace on a variable shorthand (without operator), show the current state
406+ // This handles cases like {{.Lila} or {{.Lila}}| where we want to show what was typed
407+ if (context.isTypingClosingBrace && !context.isOperatorComplete && !context.isTypingOperator && !context.isTypingValue) {
408+ // Show the current variable name as context (non-selectable)
409+ const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
410+ varNameOption.valueProvider = () => ''; // Context only
411+ varNameOption.makeSelectable = false;
412+ varNameOption.sortPriority = 2;
413+ varNameOption.matchProvider = () => true; // Always show
414+ options.push(varNameOption);
415+ }
416+
417+ // If typing closing brace after a value operator (like {{.Lila+=4}} or {{.Lila+=4}),
418+ // show the full context (variable + operator + value)
419+ if (context.isTypingClosingBrace && context.variableOperator && context.isTypingValue) {
420+ // Show the current variable name as context (non-selectable)
421+ const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
422+ varNameOption.valueProvider = () => ''; // Context only
423+ varNameOption.makeSelectable = false;
424+ varNameOption.sortPriority = 2;
425+ varNameOption.matchProvider = () => true; // Always show
426+ options.push(varNameOption);
427+
428+ // Show the operator that was used (non-selectable)
429+ const opDef = VariableOperatorDefinitions.get(context.variableOperator);
430+ if (opDef) {
431+ const opOption = new VariableOperatorAutoCompleteOption(opDef);
432+ opOption.valueProvider = () => ''; // Already typed
433+ opOption.makeSelectable = false;
434+ opOption.sortPriority = 3;
435+ opOption.matchProvider = () => true; // Always show
436+ options.push(opOption);
437+
438+ // Show value context info (non-selectable)
439+ const valueOption = new VariableValueContextAutoCompleteOption(opDef, context.variableValue);
440+ valueOption.valueProvider = () => ''; // Context only
441+ valueOption.makeSelectable = false;
442+ valueOption.sortPriority = 4;
443+ valueOption.matchProvider = () => true; // Always show
444+ options.push(valueOption);
445+ }
446+ }
447+
448+ return options;
449+}
450+
451+/**
452+ * Builds enhanced macro autocomplete options from the MacroRegistry.
453+ * When in the flags area (before identifier), includes flag options.
454+ * When typing arguments (after ::), prioritizes the exact macro match.
455+ * @param {MacroAutoCompleteContext} context
456+ * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
457+ * @param {Object} [opts] - Additional options.
458+ * @param {boolean} [opts.isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space).
459+ * @returns {AnyMacroAutoCompleteOption[]}
460+ */
461+export function buildEnhancedMacroOptions(context, textUpToCursor, { isForced = false } = {}) {
462+ /** @type {AnyMacroAutoCompleteOption[]} */
463+ const options = [];
464+
465+ if (context.isVariableShorthand) {
466+ return buildVariableShorthandOptions(context);
467+ }
468+
469+ // Check for unclosed scoped macros and suggest closing tags
470+ // Iterate from innermost to outermost, adding optional scopes and stopping at first required scope
471+ const unclosedScopes = findUnclosedScopes(textUpToCursor);
472+ if (unclosedScopes.length > 0) {
473+ let firstRequiredPriority = 1; // Priority for the first required (non-optional) scope
474+ let optionalPriority = 3; // Lower priority for optional scopes
475+ let foundRequired = false;
476+ let elseOptionAdded = false;
477+
478+ // Iterate from innermost (last) to outermost (first)
479+ for (let i = unclosedScopes.length - 1; i >= 0; i--) {
480+ const scope = unclosedScopes[i];
481+ const isOptional = isScopeOptional(scope, textUpToCursor);
482+ const nestingLevel = unclosedScopes.length - 1 - i; // 0 = innermost
483+
484+ // If we've already found a required scope, stop adding more
485+ if (foundRequired && !isOptional) break;
486+
487+ const closingOption = new MacroClosingTagAutoCompleteOption(scope.name, {
488+ paddingBefore: scope.paddingBefore,
489+ paddingAfter: scope.paddingAfter,
490+ currentPadding: context.paddingBefore,
491+ isOptional: isOptional,
492+ nestingLevel: nestingLevel,
493+ });
494+
495+ if (isOptional) {
496+ closingOption.sortPriority = optionalPriority++;
497+ } else {
498+ // First required scope gets top priority
499+ closingOption.sortPriority = firstRequiredPriority;
500+ foundRequired = true;
501+ }
502+
503+ options.push(closingOption);
504+
505+ // If inside a scoped {{if}}, also suggest {{else}} (only once, for innermost if)
506+ if (!elseOptionAdded && scope.name === 'if') {
507+ const macroDef = macroSystem.registry.getPrimaryMacro('else');
508+ const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
509+ elseOption.sortPriority = 2;
510+ options.push(elseOption);
511+ elseOptionAdded = true;
512+ }
513+
514+ // Stop once we've added a required scope
515+ if (foundRequired) break;
516+ }
517+ }
518+
519+ // If cursor is in the flags area (before identifier starts), include flag options
520+ if (context.isInFlagsArea) {
521+ // Build flag options with priority-based sorting
522+ // Last typed flag has highest priority (1), other flags have lower priority (10)
523+ // Already-typed flags (except last) are hidden from the list
524+ const lastTypedFlag = context.flags.length > 0 ? context.flags[context.flags.length - 1] : null;
525+
526+ // Add last typed flag with high priority (so it appears at top)
527+ if (lastTypedFlag) {
528+ const lastFlagDef = MacroFlagDefinitions.get(lastTypedFlag);
529+ if (lastFlagDef) {
530+ const lastFlagOption = new MacroFlagAutoCompleteOption(lastFlagDef);
531+ // Mark as already typed - valueProvider returns empty so it doesn't re-insert
532+ lastFlagOption.valueProvider = () => '';
533+ lastFlagOption.makeSelectable = false;
534+ // High priority to appear at top (after closing tags at 1)
535+ lastFlagOption.sortPriority = 2;
536+ options.push(lastFlagOption);
537+ }
538+ }
539+
540+ // Add flags that haven't been typed yet (skip already-typed ones except last)
541+ for (const [symbol, flagDef] of MacroFlagDefinitions) {
542+ // Skip the last typed flag (already added above) and other already-typed flags
543+ if (context.flags.includes(symbol)) {
544+ continue;
545+ }
546+ const flagOption = new MacroFlagAutoCompleteOption(flagDef);
547+
548+ // Define whether this flag is selectable (and at the top), based on being implemented, and closing actually being relevant
549+ let isSelectable = flagDef.implemented;
550+ if (flagDef.type === MacroFlagType.CLOSING_BLOCK && !unclosedScopes.length) isSelectable = false;
551+ if (!isSelectable) {
552+ flagOption.valueProvider = () => '';
553+ flagOption.makeSelectable = false;
554+ }
555+ // Normal flag priority
556+ flagOption.sortPriority = isSelectable ? 10 : 12;
557+ options.push(flagOption);
558+ }
559+
560+ // Add variable shorthand prefix options (. for local, $ for global)
561+ // These allow users to type variable shorthands instead of macro names
562+ for (const [, varShorthandDef] of VariableShorthandDefinitions) {
563+ const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef);
564+ varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12)
565+ options.push(varOption);
566+ }
567+ }
568+
569+ // Get all macros from the registry (excluding hidden aliases)
570+ const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
571+
572+ // If we're typing arguments (after ::), only show the context to the matching macro
573+ // Also treat typing closing brace the same way - show details for matching macro
574+ const isTypingArgs = context.currentArgIndex >= 0;
575+ const isTypingClosingBrace = context.isTypingClosingBrace ?? false;
576+ const shouldShowMatchingMacroDetails = isTypingArgs || isTypingClosingBrace;
577+
578+ // Check if we're inside a scoped {{if}} for {{else}} selectability
579+ const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if');
580+
581+ // Track if any macro matches the identifier (for "no match" message)
582+ let hasMatchingMacro = false;
583+
584+ for (const macro of allMacros) {
585+ // Check if this macro matches the typed identifier
586+ const isExactMatch = macro.name === context.identifier;
587+ const isAliasMatch = macro.aliasOf === context.identifier;
588+
589+ if (isExactMatch || isAliasMatch) {
590+ hasMatchingMacro = true;
591+ }
592+
593+ // Only pass context to the macro that matches the identifier being typed
594+ // This ensures argument hints only show for the relevant macro
595+ /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */
596+ let macroContext = (isExactMatch || isAliasMatch) ? context : null;
597+
598+ // If no context, we pass some options for additional details though
599+ if (!macroContext) {
600+ macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({
601+ paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed
602+ flags: context.flags,
603+ currentFlag: context.currentFlag,
604+ fullText: context.fullText,
605+ });
606+ }
607+
608+ const option = new EnhancedMacroAutoCompleteOption(macro, macroContext);
609+
610+ // {{else}} is only selectable inside a scoped {{if}} block
611+ // Outside of {{if}}, it should appear in the list but not be tab-completable
612+ if (macro.name === 'else' && !isInsideScopedIf) {
613+ option.valueProvider = () => '';
614+ option.makeSelectable = false;
615+ }
616+
617+ // When typing arguments or closing brace, prioritize exact matches by putting them first
618+ if (shouldShowMatchingMacroDetails && (isExactMatch || isAliasMatch)) {
619+ options.unshift(option);
620+ } else {
621+ options.push(option);
622+ }
623+ }
624+
625+ // If typing args/closing brace but no macro matches, check for closing macro context
626+ if (shouldShowMatchingMacroDetails && !hasMatchingMacro && context.identifier.length > 0) {
627+ // Check if this is a closing macro (starts with /) - show original macro's details
628+ // Note: We look up the macro directly, not from unclosedScopes, because the closing tag
629+ // itself may have already closed the scope by this point in the text
630+ const isClosingMacro = context.identifier.startsWith('/');
631+ const closingMacroName = isClosingMacro ? context.identifier.slice(1) : null;
632+ const macroDef = closingMacroName ? macroSystem.registry.getPrimaryMacro(closingMacroName) : null;
633+
634+ if (macroDef) {
635+ // Show the original macro's details for the closing tag
636+ // Create a context that shows we're closing the scope (no argument highlight)
637+ const closingContext = /** @type {MacroAutoCompleteContext} */ ({
638+ ...context,
639+ identifier: macroDef.name,
640+ currentArgIndex: -1, // No argument highlight
641+ isClosingTag: true,
642+ });
643+ const closingOption = new EnhancedMacroAutoCompleteOption(macroDef, closingContext);
644+ closingOption.valueProvider = () => '';
645+ closingOption.makeSelectable = false;
646+ closingOption.matchProvider = () => true;
647+ closingOption.sortPriority = 0;
648+ options.unshift(closingOption);
649+ hasMatchingMacro = true; // Prevent "no match" message
650+ }
651+
652+ // Only show "no match" if we didn't find a matching closing scope
653+ if (!hasMatchingMacro) {
654+ const noMatchOption = new SimpleAutoCompleteOption({
655+ name: context.identifier,
656+ symbol: '❌',
657+ description: `No macro found: "${context.identifier}"`,
658+ detailedDescription: `The macro name <code>${context.identifier}</code> does not exist.<br><br>Check spelling or use a different macro name.`,
659+ type: 'error',
660+ });
661+ noMatchOption.valueProvider = () => '';
662+ noMatchOption.makeSelectable = false;
663+ noMatchOption.matchProvider = () => true; // Always show
664+ noMatchOption.sortPriority = 0; // Top priority
665+ options.unshift(noMatchOption);
666+ }
667+ }
668+
669+ return options;
670+}
671+
672+/**
673+ * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
674+ * @param {MacroAutoCompleteContext} context
675+ * @param {MacroDefinition[]} allMacros
676+ * @param {string} macroInnerText - The text inside the macro braces (e.g., " if pers" from "{{ if pers").
677+ * @returns {AutoCompleteOption[]}
678+ */
679+export function buildIfConditionOptions(context, allMacros, macroInnerText) {
680+ /** @type {AutoCompleteOption[]} */
681+ const options = [];
682+
683+ // Calculate padding from the original macro text for matching whitespace on completion
684+ // e.g., " if pers" -> leading padding = " " (whitespace before 'if', used before '}}')
685+ const leadingMatch = macroInnerText.match(/^(\s*)/);
686+ const paddingAfter = leadingMatch ? leadingMatch[1] : '';
687+
688+ // Get the condition text being typed (trimmed for detection)
689+ const conditionText = (context.args[0] || '').trim();
690+
691+ // Check for inversion prefix (!) - also trim whitespace after !
692+ const hasInversionPrefix = conditionText.startsWith('!');
693+ const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText;
694+
695+ const inversionOption = new SimpleAutoCompleteOption({
696+ name: '!',
697+ symbol: '🔁',
698+ description: 'Invert condition (NOT)',
699+ detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.<br><br>Example: <code>{{if !myVar}}</code> executes when <code>myVar</code> is empty or zero.',
700+ type: 'inverse',
701+ });
702+
703+ // Check if condition starts with a variable shorthand prefix (with or without !)
704+ const isTypingVariableShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
705+
706+ if (isTypingVariableShorthand) {
707+ // User is typing a variable shorthand - reuse #buildVariableShorthandOptions
708+ const prefix = /** @type {'.'|'$'} */ (conditionAfterInversion[0]);
709+ const varNameTyped = conditionAfterInversion.slice(1); // Variable name after the prefix
710+
711+ // If inverted, show the ! as non-selectable context
712+ if (hasInversionPrefix) {
713+ inversionOption.valueProvider = () => ''; // Already typed
714+ inversionOption.makeSelectable = false;
715+ inversionOption.sortPriority = 0;
716+ options.push(inversionOption);
717+ }
718+
719+ // Create a synthetic context for #buildVariableShorthandOptions
720+ /** @type {MacroAutoCompleteContext} */
721+ const varContext = {
722+ ...context,
723+ isVariableShorthand: true,
724+ variablePrefix: prefix,
725+ variableName: varNameTyped,
726+ isTypingVariableName: true,
727+ isTypingOperator: false,
728+ isTypingValue: false,
729+ isOperatorComplete: false,
730+ hasInvalidTrailingChars: false,
731+ variableOperator: null,
732+ variableValue: '',
733+ };
734+
735+ const varOptions = buildVariableShorthandOptions(varContext, { forIfCondition: true, paddingAfter });
736+ options.push(...varOptions);
737+ return options;
738+ }
739+
740+ // Not typing a variable shorthand - show macro options, variable shorthand prefixes, and inversion
741+
742+ // Show ! inversion option at the top when nothing typed, or keep it visible (non-selectable) if already typed
743+ if (conditionText.length === 0) {
744+ // Nothing typed - offer ! as selectable option
745+ inversionOption.valueProvider = () => '!';
746+ inversionOption.makeSelectable = true;
747+ inversionOption.sortPriority = -1; // Show at very top
748+ options.push(inversionOption);
749+ } else if (hasInversionPrefix && conditionAfterInversion.length === 0) {
750+ // Just ! typed - show it as non-selectable context, then show macro names and variable prefixes
751+ inversionOption.valueProvider = () => ''; // Already typed
752+ inversionOption.makeSelectable = false;
753+ inversionOption.sortPriority = -1;
754+ options.push(inversionOption);
755+ }
756+
757+ // Add variable shorthand prefix options when no content typed yet (or just ! typed)
758+ if (conditionAfterInversion.length === 0) {
759+ for (const [, prefixDef] of VariableShorthandDefinitions) {
760+ const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
761+ // Complete with just the prefix symbol
762+ prefixOption.valueProvider = () => prefixDef.type;
763+ prefixOption.makeSelectable = true;
764+ prefixOption.sortPriority = 0; // Show at top
765+ options.push(prefixOption);
766+ }
767+ }
768+
769+ // Add zero-arg macros as condition shorthand options
770+ for (const macro of allMacros) {
771+ // Only include macros that require zero arguments (can be auto-resolved)
772+ if (macro.minArgs !== 0) continue;
773+
774+ // Skip internal/utility macros that don't make sense as conditions
775+ if (['else', 'noop', 'trim', '//'].includes(macro.name)) continue;
776+
777+ const option = new EnhancedMacroAutoCompleteOption(macro, {
778+ noBraces: true,
779+ paddingAfter,
780+ closeWithBraces: true,
781+ });
782+ options.push(option);
783+ }
784+
785+ return options;
786+}
787+
788+/**
789+ * Finds macro boundaries at a given cursor position in any text.
790+ * Works independently of slash command parsing.
791+ *
792+ * @param {string} text - The full text content.
793+ * @param {number} cursorPos - The cursor position in the text.
794+ * @returns {{ start: number, end: number, content: string } | null}
795+ */
796+export function findMacroAtCursor(text, cursorPos) {
797+ // Search backwards for opening {{ while tracking nesting depth for nested macros
798+ let openPos = -1;
799+ let depth = 0;
800+
801+ // If cursor is right after }}, those are the closing braces of the macro we're looking for,
802+ // not nested braces. Skip them by starting the search before them.
803+ let searchStart = cursorPos - 1;
804+ let cursorAfterClosingBraces = false;
805+ if (cursorPos >= 2 && text[cursorPos - 1] === '}' && text[cursorPos - 2] === '}') {
806+ searchStart = cursorPos - 3; // Start before the }}
807+ cursorAfterClosingBraces = true;
808+ }
809+
810+ for (let i = searchStart; i >= 0; i--) {
811+ if (text[i] === '}' && i > 0 && text[i - 1] === '}') {
812+ // Found }}, going backwards means we're entering a nested macro
813+ depth++;
814+ i--; // Skip the other brace
815+ continue;
816+ }
817+ if (text[i] === '{' && i > 0 && text[i - 1] === '{') {
818+ if (depth > 0) {
819+ // This {{ closes a nested macro we entered going backwards
820+ depth--;
821+ i--; // Skip the other brace
822+ continue;
823+ }
824+ // Found our opening {{ at depth 0
825+ openPos = i - 1;
826+ break;
827+ }
828+ }
829+
830+ if (openPos === -1) return null;
831+
832+ // Search forwards for closing }} while tracking nesting depth
833+ let closePos = -1;
834+
835+ // If cursor is right after }}, we already know where the closing braces are
836+ if (cursorAfterClosingBraces) {
837+ closePos = cursorPos;
838+ } else {
839+ depth = 0;
840+ for (let i = cursorPos; i < text.length - 1; i++) {
841+ if (text[i] === '{' && text[i + 1] === '{') {
842+ // Found {{, entering a nested macro
843+ depth++;
844+ i++; // Skip the other brace
845+ continue;
846+ }
847+ if (text[i] === '}' && text[i + 1] === '}') {
848+ if (depth > 0) {
849+ // This }} closes a nested macro
850+ depth--;
851+ i++; // Skip the other brace
852+ continue;
853+ }
854+ // Found our closing }} at depth 0
855+ closePos = i + 2;
856+ break;
857+ }
858+ }
859+
860+ if (closePos === -1) {
861+ closePos = text.length;
862+ }
863+ }
864+
865+ const hasClosingBraces = closePos <= text.length && text.slice(closePos - 2, closePos) === '}}';
866+ const content = text.slice(openPos + 2, hasClosingBraces ? closePos - 2 : closePos);
867+
868+ return {
869+ start: openPos,
870+ end: closePos,
871+ content,
872+ };
873+}
874+
875+/**
876+ * Gets variable names from the specified scope.
877+ *
878+ * @param {'local'|'global'} scope - The variable scope.
879+ * @returns {string[]} Array of variable names.
880+ */
881+export function getVariableNames(scope) {
882+ try {
883+ // Import chat_metadata and extension_settings dynamically to avoid circular deps
884+ // These are the same sources used by commonEnumProviders.variables
885+ if (scope === 'local') {
886+ // Local variables are in chat_metadata.variables
887+ return Object.keys(chat_metadata?.variables ?? {});
888+ } else {
889+ // Global variables are in extension_settings.variables.global
890+ return Object.keys(extension_settings?.variables?.global ?? {});
891+ }
892+ } catch {
893+ return [];
894+ }
895+}
896+
897+/**
898+ * Core function to build macro autocomplete results.
899+ * Used by both SlashCommandParser (slash command context) and MacroAutoComplete (free text).
900+ *
901+ * This is the shared implementation that handles:
902+ * - Scoped content detection and context display
903+ * - {{if}} condition special handling
904+ * - Variable shorthand syntax (.var, $var)
905+ * - Flag handling
906+ * - Regular macro options
907+ *
908+ * @param {string} text - The full text content.
909+ * @param {number} cursorPos - The cursor position.
910+ * @param {BuildMacroAutoCompleteOptions} [options={}] - Optional pre-computed values.
911+ * @returns {Promise<AutoCompleteNameResult|null>}
912+ */
913+export async function buildMacroAutoCompleteResult(text, cursorPos, {
914+ macro = null,
915+ textUpToCursor = null,
916+ unclosedScopes = null,
917+ isForced = false,
918+} = {}) {
919+ // Compute textUpToCursor if not provided
920+ if (textUpToCursor === null) {
921+ textUpToCursor = text.slice(0, cursorPos);
922+ }
923+
924+ // Compute unclosedScopes if not provided
925+ if (unclosedScopes === null) {
926+ unclosedScopes = findUnclosedScopes(textUpToCursor);
927+ }
928+
929+ // Filter out optional scopes unless forced (Ctrl+Space)
930+ // This prevents intrusive hints for macros like {{trim}} where scope is optional
931+ const filteredScopes = filterOptionalScopes(unclosedScopes, textUpToCursor, isForced);
932+
933+ // If cursor is NOT inside a macro, check if we're in scoped content
934+ if (!macro) {
935+ if (filteredScopes.length > 0) {
936+ const scopedMacro = filteredScopes[filteredScopes.length - 1];
937+
938+ // Find where the opening macro ends
939+ const openingEnd = text.indexOf('}}', scopedMacro.startOffset);
940+ if (openingEnd !== -1 && cursorPos >= openingEnd + 2) {
941+ // We're in scoped content - show parent macro's details
942+ const macroContent = text.slice(scopedMacro.startOffset + 2, openingEnd);
943+ const baseContext = parseMacroContext(macroContent, macroContent.length);
944+
945+ // Check if this scope is optional (for display purposes)
946+ const scopeIsOptional = isScopeOptional(scopedMacro, textUpToCursor);
947+
948+ const scopedContext = {
949+ ...baseContext,
950+ currentArgIndex: baseContext.args.length,
951+ isInScopedContent: true,
952+ isScopedContentOptional: scopeIsOptional,
953+ scopedMacroName: scopedMacro.name,
954+ };
955+
956+ await onboardingExperimentalMacroEngine('scoped macros');
957+
958+ const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
959+ if (macroDef) {
960+ const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
961+ scopedOption.valueProvider = () => '';
962+ scopedOption.makeSelectable = false;
963+
964+ return new AutoCompleteNameResult(
965+ scopedMacro.name,
966+ scopedMacro.startOffset + 2,
967+ [scopedOption],
968+ false,
969+ );
970+ }
971+ }
972+ }
973+ return null;
974+ }
975+
976+ // Cursor is inside a macro - parse context
977+ const cursorInMacro = cursorPos - macro.start - 2;
978+ const context = parseMacroContext(macro.content, cursorInMacro);
979+
980+ // Check if cursor is at/after closing }}
981+ const macroEndsBrackets = text.slice(macro.end - 2, macro.end) === '}}';
982+ const isCursorAtClosing = macroEndsBrackets && cursorPos >= macro.end - 1;
983+
984+ if (isCursorAtClosing) {
985+ // Cursor is at the closing }} - check if this is an unclosed scoped macro
986+ if (filteredScopes.length > 0) {
987+ const scopedMacro = filteredScopes[filteredScopes.length - 1];
988+ // Check if the current macro IS the unclosed scoped macro
989+ if (scopedMacro.startOffset === macro.start) {
990+ // Show scoped context - cursor is right at the end of the opening tag
991+ // Check if this scope is optional (for display purposes)
992+ const scopeIsOptional = isScopeOptional(scopedMacro, textUpToCursor);
993+
994+ const scopedContext = {
995+ ...context,
996+ currentArgIndex: context.args.length,
997+ isInScopedContent: true,
998+ isScopedContentOptional: scopeIsOptional,
999+ scopedMacroName: scopedMacro.name,
1000+ };
1001+
1002+ const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
1003+ if (macroDef) {
1004+ const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
1005+ scopedOption.valueProvider = () => '';
1006+ scopedOption.makeSelectable = false;
1007+
1008+ return new AutoCompleteNameResult(
1009+ scopedMacro.name,
1010+ macro.start + 2,
1011+ [scopedOption],
1012+ false,
1013+ );
1014+ }
1015+ }
1016+ }
1017+
1018+ // Check if this is a closing tag ({{/macroName}}) - show original macro's details
1019+ // Note: We look up the macro directly, not from unclosedScopes, because the closing tag
1020+ // itself has already closed the scope by this point in the text
1021+ if (context.identifier.startsWith('/')) {
1022+ const closingMacroName = context.identifier.slice(1);
1023+ const macroDef = macroSystem.registry.getPrimaryMacro(closingMacroName);
1024+ if (macroDef) {
1025+ const closingContext = /** @type {MacroAutoCompleteContext} */ ({
1026+ ...context,
1027+ identifier: macroDef.name,
1028+ currentArgIndex: -1, // No argument highlight
1029+ isClosingTag: true,
1030+ });
1031+ const closingOption = new EnhancedMacroAutoCompleteOption(macroDef, closingContext);
1032+ closingOption.valueProvider = () => '';
1033+ closingOption.makeSelectable = false;
1034+
1035+ return new AutoCompleteNameResult(
1036+ macroDef.name,
1037+ macro.start + 2,
1038+ [closingOption],
1039+ false,
1040+ );
1041+ }
1042+ }
1043+
1044+ // Not a scoped macro, just clear arg highlighting
1045+ context.currentArgIndex = -1;
1046+ }
1047+
1048+ // Use the identifier from context (handles whitespace and flags)
1049+ // Start position must be where the identifier actually begins (after whitespace/flags)
1050+ // so that the autocomplete range calculation works correctly
1051+ const identifier = context.identifier;
1052+ const identifierStartInText = macro.start + 2 + context.identifierStart;
1053+
1054+ // Special case for {{if}} condition: use the condition text for matching/replacement
1055+ const isTypingIfCondition = context.identifier === 'if' && context.currentArgIndex === 0;
1056+ if (isTypingIfCondition) {
1057+ // Get the typed condition text and calculate its start position
1058+ const conditionText = context.args[0] || '';
1059+ // Find where the condition argument starts in the macro text
1060+ const separatorMatch = macro.content.match(/^.*?if\s*(?:::?)\s*/);
1061+ const spaceMatch = macro.content.match(/^.*?if\s+/);
1062+ let conditionStartOffset;
1063+ if (separatorMatch) {
1064+ conditionStartOffset = separatorMatch[0].length;
1065+ } else if (spaceMatch) {
1066+ conditionStartOffset = spaceMatch[0].length;
1067+ } else {
1068+ conditionStartOffset = context.identifierStart + identifier.length;
1069+ }
1070+ const conditionStartInText = macro.start + 2 + conditionStartOffset;
1071+
1072+ // Build if-condition options using macroContent for padding calculation
1073+ const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
1074+ const options = buildIfConditionOptions(context, allMacros, macro.content);
1075+
1076+ // For variable shorthand in {{if}} condition, adjust identifier and start position
1077+ // Same fix as for regular variable shorthands - identifier must be just the var name
1078+ // Also handle ! inversion prefix: !.var or !$var or !macroName
1079+ const trimmedCondition = conditionText.trim();
1080+ const hasInversion = trimmedCondition.startsWith('!');
1081+ // Trim whitespace after ! to handle "! $myvar" syntax
1082+ const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition;
1083+ const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
1084+ let resultIdentifier = conditionText;
1085+ let resultStart = conditionStartInText;
1086+
1087+ if (isTypingVarShorthand) {
1088+ // Identifier = just the variable name part (without prefix and without !)
1089+ resultIdentifier = conditionAfterInversion.slice(1);
1090+ // Start = after the ! (if any) and the prefix
1091+ const prefixChar = conditionAfterInversion[0];
1092+ const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0);
1093+ resultStart = conditionStartInText + prefixPosInCondition + 1;
1094+ } else if (hasInversion && conditionAfterInversion.length === 0) {
1095+ // Just ! (possibly with whitespace) typed - identifier should be empty so other options can match
1096+ resultIdentifier = '';
1097+ // Start at end of actual condition text (including any whitespace after !)
1098+ // This ensures cursor is within the name range for filtering
1099+ resultStart = conditionStartInText + conditionText.length;
1100+ } else if (hasInversion && conditionAfterInversion.length > 0) {
1101+ // Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name
1102+ resultIdentifier = conditionAfterInversion;
1103+ // Start = after the ! and any whitespace, at the beginning of the macro name
1104+ const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion);
1105+ resultStart = conditionStartInText + macroNameStart;
1106+ }
1107+
1108+ await onboardingExperimentalMacroEngine('{{if}} macro');
1109+
1110+ return new AutoCompleteNameResult(
1111+ resultIdentifier,
1112+ resultStart,
1113+ options,
1114+ false,
1115+ () => isTypingVarShorthand
1116+ ? 'Enter a variable name for the condition'
1117+ : 'Use {{macro}} syntax for dynamic conditions',
1118+ () => isTypingVarShorthand
1119+ ? 'Enter a variable name or select from the list'
1120+ : 'Enter a macro name or {{macro}} for the condition',
1121+ );
1122+ }
1123+
1124+ // Build regular macro options
1125+ /** @type {()=>string|undefined} */
1126+ let makeNoMatchText = undefined;
1127+ /** @type {()=>string|undefined} */
1128+ let makeNoOptionsText = undefined;
1129+
1130+ const options = buildEnhancedMacroOptions(context, textUpToCursor);
1131+
1132+ // For variable shorthands, calculate the correct identifier and start position
1133+ // based on what the user is currently typing (variable name, operator, or value)
1134+ let resultIdentifier = identifier;
1135+ let resultStart = identifierStartInText;
1136+ if (context.isVariableShorthand && context.variablePrefix) {
1137+ // Find where the prefix is in the macro content
1138+ const prefixIndex = macro.content.indexOf(context.variablePrefix);
1139+
1140+ if (context.isTypingVariableName) {
1141+ // Typing variable name: identifier = variableName, start = after prefix
1142+ resultIdentifier = context.variableName;
1143+ if (prefixIndex >= 0) {
1144+ resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix
1145+ }
1146+ } else if (context.isTypingOperator) {
1147+ // Typing operator: identifier = partial operator or current operator, start = after variable name
1148+ resultIdentifier = context.partialOperator || context.variableOperator || '';
1149+ // Use actual variableNameEnd position from parsing (accounts for whitespace)
1150+ resultStart = macro.start + 2 + context.variableNameEnd;
1151+ // Skip whitespace between variable name and operator
1152+ while (resultStart < cursorPos && /\s/.test(text[resultStart])) {
1153+ resultStart++;
1154+ }
1155+ } else if (context.isOperatorComplete) {
1156+ // Operator complete (++ or --) - show context but no value input needed
1157+ resultIdentifier = '';
1158+ resultStart = cursorPos; // Cursor at end
1159+ } else if (context.hasInvalidTrailingChars) {
1160+ // Invalid chars after variable name: show the invalid chars for warning
1161+ resultIdentifier = context.invalidTrailingChars || '';
1162+ // Use actual variableNameEnd position from parsing
1163+ resultStart = macro.start + 2 + context.variableNameEnd;
1164+ } else if (context.isTypingValue && !context.isTypingClosingBrace) {
1165+ // Typing value: identifier = value being typed, start = after operator
1166+ resultIdentifier = context.variableValue;
1167+ // Use actual operatorEnd position from parsing (accounts for whitespace)
1168+ resultStart = macro.start + 2 + context.variableOperatorEnd;
1169+ // Skip any whitespace between operator and value
1170+ while (resultStart < cursorPos && /\s/.test(text[resultStart])) {
1171+ resultStart++;
1172+ }
1173+
1174+ makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
1175+ makeNoOptionsText = () => 'Enter a variable value';
1176+ } else if (context.isTypingClosingBrace) {
1177+ // Typing closing brace on variable shorthand - show context, no replacement needed
1178+ resultIdentifier = '';
1179+ resultStart = cursorPos;
1180+ } else {
1181+ // Fallback: use variable name
1182+ resultIdentifier = context.variableName;
1183+ if (prefixIndex >= 0) {
1184+ resultStart = macro.start + 2 + prefixIndex + 1;
1185+ }
1186+ }
1187+
1188+ if (!makeNoMatchText && !makeNoOptionsText) {
1189+ makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.';
1190+ makeNoOptionsText = () => 'Enter a variable name to create or use a new variable';
1191+ }
1192+ }
1193+
1194+ return new AutoCompleteNameResult(
1195+ resultIdentifier,
1196+ resultStart,
1197+ options,
1198+ false,
1199+ makeNoMatchText,
1200+ makeNoOptionsText,
1201+ );
1202+}
1203+
1204+/**
1205+ * Entry point for macro autocomplete in free text contexts.
1206+ * Finds the macro at cursor position and delegates to the shared builder.
1207+ *
1208+ * @param {string} text - The full text content.
1209+ * @param {number} cursorPos - The cursor position.
1210+ * @param {Object} [options={}] - Additional options.
1211+ * @param {boolean} [options.isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space).
1212+ * @returns {Promise<AutoCompleteNameResult|null>}
1213+ */
1214+export async function getMacroAutoCompleteAt(text, cursorPos, { isForced = false } = {}) {
1215+ const macro = findMacroAtCursor(text, cursorPos);
1216+ return buildMacroAutoCompleteResult(text, cursorPos, { macro, isForced });
1217+}
public/scripts/backgrounds.js+129 -3
@@ -3,7 +3,7 @@ import { characters, chat_metadata, eventSource, event_types, generateQuietPromp
33import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
44import { SlashCommand } from './slash-commands/SlashCommand.js';
55import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
66import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js';
77import { debounce_timeout } from './constants.js';
88import { t } from './i18n.js';
99import { Popup } from './popup.js';
@@ -42,6 +42,12 @@ const THUMBNAIL_CONFIG = {
4242};
4343
4444/**
45+ * Cache for image metadata.
46+ * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>}
47+ */
48+const METADATA_CACHE = new Map();
49+
50+/**
4551 * Background source types.
4652 * @readonly
4753 * @enum {number}
@@ -52,6 +58,18 @@ const BG_SOURCES = {
5258};
5359
5460/**
61+ * Background sorting options.
62+ * @readonly
63+ * @enum {string}
64+ */
65+const BG_SORT_OPTIONS = {
66+ AZ: 'az',
67+ ZA: 'za',
68+ NEWEST: 'newest',
69+ OLDEST: 'oldest',
70+};
71+
72+/**
5573 * Mapping of background sources to their corresponding tab IDs.
5674 * @readonly
5775 * @type {Record<string, string>}
@@ -67,14 +85,56 @@ const BG_TABS = Object.freeze({
6785 */
6886let lazyLoadObserver = null;
6987
88+/**
89+ * Cache for the current list of system background filenames.
90+ * Used to re-sort backgrounds without refetching from the server.
91+ * @type {string[]}
92+ */
93+let cachedSystemBackgrounds = [];
94+
7095export let background_settings = {
7196 name: '__transparent.png',
7297 url: generateUrlParameter('__transparent.png', false),
7398 fitting: 'classic',
7499 animation: false,
100+ sortOrder: BG_SORT_OPTIONS.AZ,
75101};
76102
77103/**
104+ * Sorts an array of background filenames based on the current sort order.
105+ * @param {string[]} backgrounds - Array of background filenames
106+ * @param {boolean} isCustom - Whether these are custom (chat) backgrounds
107+ * @returns {string[]} Sorted array of background filenames
108+ */
109+function sortBackgrounds(backgrounds, isCustom = false) {
110+ const sortOrder = background_settings.sortOrder || BG_SORT_OPTIONS.AZ;
111+
112+ return [...backgrounds].sort((a, b) => {
113+ switch (sortOrder) {
114+ case BG_SORT_OPTIONS.AZ:
115+ return sortIgnoreCaseAndAccents(a, b);
116+ case BG_SORT_OPTIONS.ZA:
117+ return sortIgnoreCaseAndAccents(b, a);
118+ case BG_SORT_OPTIONS.NEWEST:
119+ case BG_SORT_OPTIONS.OLDEST: {
120+ const keyA = isCustom ? a : `backgrounds/${a}`;
121+ const keyB = isCustom ? b : `backgrounds/${b}`;
122+ const metaA = METADATA_CACHE.get(keyA);
123+ const metaB = METADATA_CACHE.get(keyB);
124+ const timestampA = metaA?.addedTimestamp ?? 0;
125+ const timestampB = metaB?.addedTimestamp ?? 0;
126+ // Newest first (descending) or oldest first (ascending)
127+ return sortOrder === BG_SORT_OPTIONS.NEWEST
128+ ? timestampB - timestampA
129+ : timestampA - timestampB;
130+ }
131+ default:
132+ return 0;
133+ }
134+ });
135+}
136+
137+/**
78138 * Creates a single thumbnail DOM element. The CSS now handles all sizing.
79139 * @param {object} imageData - Data for the image (filename, isCustom).
80140 * @returns {HTMLElement} The created thumbnail element.
@@ -89,6 +149,18 @@ function createThumbnailElement(imageData) {
89149 clipper.className = 'thumbnail-clipper lazy-load-background';
90150 clipper.style.backgroundImage = PLACEHOLDER_IMAGE;
91151
152+ // Apply dominant color and aspect ratio as placeholder if available
153+ const metadataKey = isCustom ? bg : `backgrounds/${bg}`;
154+ const metadata = METADATA_CACHE.get(metadataKey);
155+ if (metadata) {
156+ if (metadata.dominantColor) {
157+ clipper.style.backgroundColor = metadata.dominantColor;
158+ }
159+ if (metadata.aspectRatio) {
160+ thumbnail.css('aspect-ratio', metadata.aspectRatio);
161+ }
162+ }
163+
92164 const titleElement = thumbnail.find('.BGSampleTitle');
93165 clipper.appendChild(titleElement.get(0));
94166 thumbnail.append(clipper);
@@ -132,6 +204,9 @@ export function loadBackgroundSettings(settings) {
132204 if (!Object.hasOwn(backgroundSettings, 'animation')) {
133205 backgroundSettings.animation = false;
134206 }
207+ if (!backgroundSettings.sortOrder) {
208+ backgroundSettings.sortOrder = BG_SORT_OPTIONS.AZ;
209+ }
135210
136211 // If a value is already saved, use it. Otherwise, determine default based on screen size.
137212 let columns = backgroundSettings.thumbnailColumns;
@@ -140,12 +215,14 @@ export function loadBackgroundSettings(settings) {
140215 columns = isNarrowScreen ? THUMBNAIL_COLUMNS_DEFAULT_MOBILE : THUMBNAIL_COLUMNS_DEFAULT_DESKTOP;
141216 }
142217 background_settings.thumbnailColumns = columns;
218+ background_settings.sortOrder = backgroundSettings.sortOrder;
143219 applyThumbnailColumns(background_settings.thumbnailColumns);
144220
145221 setBackground(backgroundSettings.name, backgroundSettings.url);
146222 setFittingClass(backgroundSettings.fitting);
147223 $('#background_fitting').val(backgroundSettings.fitting);
148224 $('#background_thumbnails_animation').prop('checked', background_settings.animation);
225+ $('#bg-sort').val(background_settings.sortOrder);
149226 highlightSelectedBackground();
150227}
151228
@@ -429,6 +506,11 @@ async function onDeleteBackgroundClick(e) {
429506 // If it's not custom, it's a built-in background. Delete it from the server
430507 if (!isCustom) {
431508 await delBackground(bg);
509+ // Remove from cache to prevent reappearing on sort change
510+ const cacheIndex = cachedSystemBackgrounds.indexOf(bg);
511+ if (cacheIndex !== -1) {
512+ cachedSystemBackgrounds.splice(cacheIndex, 1);
513+ }
432514 } else {
433515 const list = chat_metadata[LIST_METADATA_KEY] || [];
434516 const index = list.indexOf(bg);
@@ -517,7 +599,8 @@ function renderSystemBackgrounds(backgrounds) {
517599
518600 if (sourceList.length === 0) return;
519601
520- sourceList.forEach(bg => {
602+ const sortedList = sortBackgrounds(sourceList, false);
603+ sortedList.forEach(bg => {
521604 const imageData = { filename: bg, isCustom: false };
522605 const thumbnail = createThumbnailElement(imageData);
523606 container.append(thumbnail);
@@ -538,7 +621,8 @@ function renderChatBackgrounds(backgrounds) {
538621
539622 if (sourceList.length === 0) return;
540623
541- sourceList.forEach(bg => {
624+ const sortedList = sortBackgrounds(sourceList, true);
625+ sortedList.forEach(bg => {
542626 const imageData = { filename: bg, isCustom: true };
543627 const thumbnail = createThumbnailElement(imageData);
544628 container.append(thumbnail);
@@ -548,6 +632,8 @@ function renderChatBackgrounds(backgrounds) {
548632}
549633
550634export async function getBackgrounds() {
635+ const metadataPromise = preloadImageMetadata();
636+
551637 const response = await fetch('/api/backgrounds/all', {
552638 method: 'POST',
553639 headers: getRequestHeaders(),
@@ -557,11 +643,40 @@ export async function getBackgrounds() {
557643 const { images, config } = await response.json();
558644 Object.assign(THUMBNAIL_CONFIG, config);
559645
646+ cachedSystemBackgrounds = images;
647+
648+ await metadataPromise;
649+
560650 renderSystemBackgrounds(images);
561651 highlightSelectedBackground();
562652 }
563653}
564654
655+/**
656+ * Preloads all image metadata to use dominant colors as placeholders.
657+ * @return {Promise<void>}
658+ */
659+async function preloadImageMetadata() {
660+ try {
661+ const response = await fetch('/api/image-metadata/all', {
662+ method: 'POST',
663+ headers: getRequestHeaders(),
664+ body: JSON.stringify({ prefix: 'backgrounds/' }),
665+ });
666+ if (response.ok) {
667+ const data = await response.json();
668+ if (data?.images) {
669+ METADATA_CACHE.clear();
670+ for (const [path, metadata] of Object.entries(data.images)) {
671+ METADATA_CACHE.set(path, metadata);
672+ }
673+ }
674+ }
675+ } catch (error) {
676+ console.error('[ImageMetadata] Failed to preload metadata:', error);
677+ }
678+}
679+
565680function activateLazyLoader() {
566681 // Disconnect previous observer to prevent memory leaks
567682 if (lazyLoadObserver) {
@@ -921,6 +1036,17 @@ export function initBackgrounds() {
9211036 $('#auto_background').on('click', autoBackgroundCommand);
9221037 $('#add_bg_button').on('change', (e) => onBackgroundUploadSelected(e.originalEvent));
9231038 $('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput());
1039+ $('#bg-sort').on('change', function () {
1040+ background_settings.sortOrder = String($(this).val());
1041+ saveSettingsDebounced();
1042+ // Re-render both galleries with new sort order
1043+ renderSystemBackgrounds(cachedSystemBackgrounds);
1044+ renderChatBackgrounds();
1045+ highlightSelectedBackground();
1046+ highlightLockedBackground();
1047+ // Re-apply any active search filter
1048+ onBackgroundFilterInput();
1049+ });
9241050 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
9251051 name: 'lockbg',
9261052 callback: () => {
public/scripts/bookmarks.js+43 -23
@@ -12,6 +12,7 @@ import {
1212 saveChatConditional,
1313 saveItemizedPrompts,
1414 setActiveGroup,
15+ getCurrentChatDetails,
1516} from '../script.js';
1617import { humanizedDateTime } from './RossAscends-mods.js';
1718import {
@@ -81,30 +82,35 @@ async function getExistingChatNames() {
8182}
8283
8384async function getBookmarkName({ isReplace = false, forceName = null } = {}) {
8485 const chatNamesmainChatName = await getExistingChatNames(getCurrentChatDetails()).sessionName;
86+
87+ function buildCheckpointName(name, i) {
88+ // Strip off existing suffixes, then build new name
89+ let cleanName = name.replace(new RegExp(` - ${bookmarkNameToken}\\d+$`), '');
90+ // Strip off legacy old name prefix too
91+ cleanName = cleanName.replace(new RegExp(`^${bookmarkNameToken}\\d+ - `), '');
92+ return `${cleanName} - ${bookmarkNameToken}${i}`;
93+ }
94+ const existingChats = await getExistingChatNames();
95+ const suggestedName = getUniqueName(mainChatName, (x) => existingChats.includes(x), { nameBuilder: buildCheckpointName });
8596
8697 const body = await renderTemplateAsync('createCheckpoint', { isReplace: isReplace, suggestedName: suggestedName });
8798 let name = forceName ?? await Popup.show.input('Create Checkpoint', body, suggestedName);
8899 // Special handling for confirmed empty input (=> auto-generate name)
89100 if (name === '') {
90- for (let i = chatNames.length; i < 1000; i++) {
101+ name = suggestedName;
91- name = bookmarkNameToken + i;
92- if (!chatNames.includes(name)) {
93- break;
94- }
95- }
96102 }
97103 if (!name) {
98104 return null;
99105 }
100106
101- return `${name} - ${humanizedDateTime()}`;
107+ return name;
102108}
103109
104110function getMainChatName() {
105111 if (chat_metadata) {
106112 if (chat_metadata['.main_chat']) {
107113 return chat_metadata['.main_chat'];
108114 }
109115 // groups didn't support bookmarks before chat metadata was introduced
110116 else if (selected_group) {
@@ -112,8 +118,8 @@ function getMainChatName() {
112118 }
113119 else if (characters[this_chid].chat && characters[this_chid].chat.includes(bookmarkNameToken)) {
114120 const tokenIndex = characters[this_chid].chat.lastIndexOf(bookmarkNameToken);
115121 chat_metadata['.main_chat'] = characters[this_chid].chat.substring(0, tokenIndex).trim();
116122 return chat_metadata['.main_chat'];
117123 }
118124 }
119125 return null;
@@ -127,7 +133,7 @@ export function showBookmarksButtons() {
127133 $('#option_convert_to_group').show();
128134 }
129135
130136 if (chat_metadata['.main_chat']) {
131137 // In bookmark chat
132138 $('#option_back_to_main').show();
133139 $('#option_new_bookmark').show();
@@ -170,9 +176,23 @@ export async function createBranch(mesId) {
170176 }
171177
172178 const lastMes = chat[mesId];
173- const mainChat = selected_group ? groups?.find(x => x.id == selected_group)?.chat_id : characters[this_chid].chat;
179+ const mainChatName = (getCurrentChatDetails()).sessionName;
174180 const newMetadata = { main_chat: mainChatmainChatName };
175- let name = `Branch #${mesId} - ${humanizedDateTime()}`;
181+
182+ function buildBranchName(name, i) {
183+ // Strip off existing suffixes, then build new name
184+ let cleanName = name.replace(/ - Branch #\d+$/, '');
185+ // Strip off legacy old name prefix too
186+ cleanName = cleanName.replace(/^Branch #\d+ - /, '');
187+ return `${cleanName} - Branch #${i}`;
188+ }
189+ const existingChats = await getExistingChatNames();
190+ const name = getUniqueName(mainChatName, (x) => existingChats.includes(x), { nameBuilder: buildBranchName });
191+ if (!name) {
192+ console.error('Could not generate a unique branch name.');
193+ toastr.error('Could not generate a unique branch name.', 'Branch creation failed');
194+ return;
195+ }
176196
177197 if (selected_group) {
178198 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
@@ -184,10 +204,10 @@ export async function createBranch(mesId) {
184204 if (typeof lastMes.extra !== 'object') {
185205 lastMes.extra = {};
186206 }
187207 if (typeof lastMes.extra['.branches'] !== 'object') {
188208 lastMes.extra['.branches'] = [];
189209 }
190210 lastMes.extra['.branches'].push(name);
191211 return name;
192212}
193213
@@ -236,7 +256,7 @@ export async function createNewBookmark(mesId, { forceName = null } = {}) {
236256 await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
237257 }
238258
239259 lastMes.extra['.bookmark_link'] = name;
240260
241261 const mes = $(`.mes[mesid="${mesId}"]`);
242262 updateBookmarkDisplay(mes, name);
@@ -636,7 +656,7 @@ export function initBookmarks() {
636656
637657 const fileName = $(this).hasClass('mes_bookmark')
638658 ? $(this).closest('.mes').attr('bookmark_link')
639659 : $(this).attr('file_name').replace('.jsonl', '');
640660
641661 if (!fileName) {
642662 return;
public/scripts/cfg-scale.js+14 -14
@@ -42,13 +42,13 @@ function setCharCfg(tempValue, setting) {
4242
4343 switch (setting) {
4444 case settingType.guidance_scale:
4545 tempCharaCfg['.guidance_scale'] = Number(tempValue);
4646 break;
4747 case settingType.negative_prompt:
4848 tempCharaCfg['.negative_prompt'] = tempValue;
4949 break;
5050 case settingType.positive_prompt:
5151 tempCharaCfg['.positive_prompt'] = tempValue;
5252 break;
5353 default:
5454 return false;
@@ -239,31 +239,31 @@ function migrateSettings() {
239239
240240 if (power_user.guidance_scale) {
241241 extension_settings.cfg.global.guidance_scale = power_user.guidance_scale;
242242 delete power_user['.guidance_scale'];
243243 performSettingsSave = true;
244244 }
245245
246246 if (power_user.negative_prompt) {
247247 extension_settings.cfg.global.negative_prompt = power_user.negative_prompt;
248248 delete power_user['.negative_prompt'];
249249 performSettingsSave = true;
250250 }
251251
252252 if (chat_metadata['.cfg_negative_combine']) {
253253 chat_metadata[metadataKeys.prompt_combine] = chat_metadata['.cfg_negative_combine'];
254254 chat_metadata['.cfg_negative_combine'] = undefined;
255255 performMetaSave = true;
256256 }
257257
258258 if (chat_metadata['.cfg_negative_insertion_depth']) {
259259 chat_metadata[metadataKeys.prompt_insertion_depth] = chat_metadata['.cfg_negative_insertion_depth'];
260260 chat_metadata['.cfg_negative_insertion_depth'] = undefined;
261261 performMetaSave = true;
262262 }
263263
264264 if (chat_metadata['.cfg_negative_separator']) {
265265 chat_metadata[metadataKeys.prompt_separator] = chat_metadata['.cfg_negative_separator'];
266266 chat_metadata['.cfg_negative_separator'] = undefined;
267267 performMetaSave = true;
268268 }
269269
public/scripts/chat-templates.js+6 -6
@@ -148,8 +148,8 @@ export async function bindModelTemplates(power_user, online_status) {
148148 ?? power_user.model_templates_mappings[chatTemplateHash]
149149 ?? {};
150150 const bindingsMatch = bindModelTemplates
151151 && power_user.context.preset == bindModelTemplates['.context']
152152 && (!power_user.instruct.enabled || power_user.instruct.preset === bindModelTemplates['.instruct']);
153153
154154 const bound = [];
155155
@@ -160,21 +160,21 @@ export async function bindModelTemplates(power_user, online_status) {
160160 toastr.info(t`Context preset for ${online_status} will use defaults when loaded the next time.`);
161161 } else {
162162 if (power_user.context_derived) {
163163 if (power_user.context.preset !== bindModelTemplates['.context']) {
164164 bound.push(`${power_user.context.preset} context preset`);
165165 // toastr.info(`Bound ${power_user.context.preset} preset to currently loaded model and all models that share its chat template.`);
166166
167167 // map current preset to current chat template hash
168168 bindModelTemplates['.context'] = power_user.context.preset;
169169 }
170170 } else {
171171 toastr.warning(t`Note: Context derivation is disabled. Not including context preset.`);
172172 }
173173 if (power_user.instruct.enabled) {
174174 if (power_user.instruct_derived) {
175175 if (power_user.instruct.preset !== bindModelTemplates['.instruct']) {
176176 bound.push(`${power_user.instruct.preset} instruct preset`);
177177 bindModelTemplates['.instruct'] = power_user.instruct.preset;
178178 }
179179 } else {
180180 toastr.warning(t`Note: Instruct derivation is disabled. Not including instruct preset.`);
public/scripts/chats.js+9 -4
@@ -685,7 +685,7 @@ export function formatCreatorNotes(text, avatarId) {
685685 const preference = new StylesPreference(avatarId);
686686 const sanitizeStyles = !preference.get();
687687 const decodeStyleParam = { prefix: sanitizeStyles ? '#creator_notes_spoiler ' : '' };
688688 /** @type {import('dompurify')DOMPurify.Config & { MESSAGE_SANITIZE: boolean }} */
689689 const config = {
690690 RETURN_DOM: false,
691691 RETURN_DOM_FRAGMENT: false,
@@ -1911,13 +1911,13 @@ export function addDOMPurifyHooks() {
19111911 });
19121912
19131913 DOMPurify.addHook('uponSanitizeAttribute', (node, data, config) => {
19141914 if (!config['.MESSAGE_SANITIZE']) {
19151915 return;
19161916 }
19171917
19181918 /* Retain the classes on UI elements of messages that interact with the main UI */
19191919 const permittedNodeTypes = ['BUTTON', 'DIV'];
19201920 if (config['.MESSAGE_ALLOW_SYSTEM_UI'] && node.classList.contains('menu_button') && permittedNodeTypes.includes(node.nodeName)) {
19211921 return;
19221922 }
19231923
@@ -1938,7 +1938,7 @@ export function addDOMPurifyHooks() {
19381938 });
19391939
19401940 DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
19411941 if (!config['.MESSAGE_SANITIZE']) {
19421942 return;
19431943 }
19441944
@@ -2239,6 +2239,11 @@ export function initChatUtilities() {
22392239 wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter');
22402240 const textarea = document.createElement('textarea');
22412241 textarea.dataset.for = broId;
2242+ if (bro[0].dataset.macros !== undefined) {
2243+ textarea.dataset.macros = bro[0].dataset.macros;
2244+ textarea.dataset.macrosAutocomplete = 'always'; // Always show autocomplete in expanded editor
2245+ textarea.dataset.macrosAutocompleteStyle = 'expanded'; // Use expanded autocomplete style
2246+ }
22422247 textarea.value = String(contentEditable ? bro[0].innerText : bro.val());
22432248 textarea.classList.add('height100p', 'wide100p', 'maximized_textarea');
22442249 bro.hasClass('monospace') && textarea.classList.add('monospace');
public/scripts/events.js+4 -1
@@ -1,6 +1,7 @@
11import { EventEmitter } from '../lib/eventemitter.js';
22
33export const event_types = {
4+ APP_INITIALIZED: 'app_initialized',
45 APP_READY: 'app_ready',
56 EXTRAS_CONNECTED: 'extras_connected',
67 MESSAGE_SWIPED: 'message_swiped',
@@ -16,6 +17,8 @@ export const event_types = {
1617 MORE_MESSAGES_LOADED: 'more_messages_loaded',
1718 IMPERSONATE_READY: 'impersonate_ready',
1819 CHAT_CHANGED: 'chat_id_changed',
20+ // TODO: Naming convention is inconsistent with other events
21+ CHAT_LOADED: 'chatLoaded',
1922 GENERATION_AFTER_COMMANDS: 'GENERATION_AFTER_COMMANDS',
2023 GENERATION_STARTED: 'generation_started',
2124 GENERATION_STOPPED: 'generation_stopped',
@@ -95,4 +98,4 @@ export const event_types = {
9598 MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted',
9699};
97100
98101export const eventSource = new EventEmitter([event_types.APP_READY, event_types.APP_INITIALIZED]);
public/scripts/extensions-slashcommands.js+22 -37
@@ -1,11 +1,11 @@
11import { disableExtension, enableExtension, extension_settingsextensionNames, extensionNamesfindExtension } from './extensions.js';
22import { SlashCommand } from './slash-commands/SlashCommand.js';
33import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
44import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
55import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
66import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
77import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
88import { equalsIgnoreCaseAndAccents, isFalseBoolean, isTrueBoolean } from './utils.js';
99
1010/**
1111 * @param {'enable' | 'disable' | 'toggle'} action - The action to perform on the extension
@@ -22,30 +22,28 @@ function getExtensionActionCallback(action) {
2222 }
2323
2424 const reload = !isFalseBoolean(args?.reload?.toString());
2525 const internalExtensionNameextension = findExtension(extensionName);
2626 if (!internalExtensionNameextension) {
2727 toastr.warning(`Extension ${extensionName} does not exist.`);
2828 return '';
2929 }
3030
31- const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
31+ if (action === 'enable' && extension.enabled) {
32-
32+ toastr.info(`Extension ${extension.name} is already enabled.`);
33- if (action === 'enable' && isEnabled) {
33+ return extension.name;
34- toastr.info(`Extension ${extensionName} is already enabled.`);
35- return internalExtensionName;
3634 }
3735
3836 if (action === 'disable' && !isEnabledextension.enabled) {
3937 toastr.info(`Extension ${extensionNameextension.name} is already disabled.`);
4038 return internalExtensionNameextension.name;
4139 }
4240
4341 if (action === 'toggle') {
4442 action = isEnabledextension.enabled ? 'disable' : 'enable';
4543 }
4644
4745 if (reload) {
4846 toastr.info(`${action.charAt(0).toUpperCase() + action.slice(1)}ing extension ${extensionNameextension.name} and reloading...`);
4947
5048 // Clear input, so it doesn't stay because the command didn't "finish",
5149 // and wait for a bit to both show the toast and let the clear bubble through.
@@ -54,36 +52,24 @@ function getExtensionActionCallback(action) {
5452 }
5553
5654 if (action === 'enable') {
5755 await enableExtension(internalExtensionNameextension.name, reload);
5856 } else {
5957 await disableExtension(internalExtensionNameextension.name, reload);
6058 }
6159
6260 toastr.success(`Extension ${extensionNameextension.name} ${action}d.`);
6361
6462
6563 console.info(`Extension ${action}ed: ${extensionNameextension.name}`);
6664 if (!reload) {
6765 console.info('Reload not requested, so page needs to be reloaded manually for changes to take effect.');
6866 }
6967
7068 return internalExtensionNameextension.name;
7169 };
7270}
7371
7472/**
75- * Finds an extension by name, allowing omission of the "third-party/" prefix.
76- *
77- * @param {string} name - The name of the extension to find
78- * @returns {string?} - The matched extension name or undefined if not found
79- */
80-function findExtension(name) {
81- return extensionNames.find(extName => {
82- return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
83- });
84-}
85-
86-/**
8773 * Provides an array of SlashCommandEnumValue objects based on the extension names.
8874 * Each object contains the name of the extension and a description indicating if it is a third-party extension.
8975 *
@@ -244,14 +230,13 @@ export function registerExtensionSlashCommands() {
244230 name: 'extension-state',
245231 callback: async (_, extensionName) => {
246232 if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
247233 const internalExtensionNameextension = findExtension(extensionName);
248234 if (!internalExtensionNameextension) {
249235 toastr.warning(`Extension ${extensionName} does not exist.`);
250236 return '';
251237 }
252238
253- const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
239+ return String(extension.enabled);
254- return String(isEnabled);
255240 },
256241 returns: 'The state of the extension, whether it is enabled.',
257242 unnamedArgumentList: [
@@ -282,8 +267,8 @@ export function registerExtensionSlashCommands() {
282267 aliases: ['extension-installed'],
283268 callback: async (_, extensionName) => {
284269 if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
285270 const existsextension = findExtension(extensionName) !== undefined;
286271 return existsextension !== null ? 'true' : 'false';
287272 },
288273 returns: 'Whether the extension exists and is installed.',
289274 unnamedArgumentList: [
public/scripts/extensions.js+152 -3
@@ -4,7 +4,7 @@ import { eventSource, event_types, saveSettings, saveSettingsDebounced, getReque
44import { showLoader } from './loader.js';
55import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
66import { renderTemplate, renderTemplateAsync } from './templates.js';
77import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
88import { getContext } from './st-context.js';
99import { isAdmin } from './user.js';
1010import { addLocaleData, getCurrentLocale, t } from './i18n.js';
@@ -300,6 +300,64 @@ function onEnableExtensionClick() {
300300}
301301
302302/**
303+ * Handles toggling all extensions on or off.
304+ * @param {Object[]} extensionsToToggle
305+ * @param {JQuery<HTMLElement>} toggleContainer
306+ * @returns {Object[]} Updated extensionsToToggle array
307+ */
308+function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
309+ const extensionNames = Object.keys(manifests);
310+ const thirdPartyExtensions = extensionNames.filter(name => ['local', 'global'].includes(getExtensionType(name)));
311+
312+ const checkIfDisabled = (name) => {
313+ const toggle = extensionsToToggle.find(ext => ext.name === name);
314+ return toggle
315+ ? !toggle.enable
316+ : extension_settings.disabledExtensions.includes(name);
317+ };
318+
319+ if (thirdPartyExtensions.length === 0) return [];
320+
321+ let enable = true;
322+
323+ for (const name of thirdPartyExtensions) {
324+ const isEnabled = !checkIfDisabled(name);
325+
326+ if (isEnabled) {
327+ enable = false;
328+ break;
329+ }
330+ }
331+
332+ const toggleHandler = enable ? enableExtension : disableExtension;
333+
334+ for (const name of thirdPartyExtensions) {
335+ const isDisabled = checkIfDisabled(name);
336+ const doToggleExtension = enable ? isDisabled : !isDisabled;
337+
338+ if (doToggleExtension) {
339+ const toggle = extensionsToToggle.find(ext => ext.name === name);
340+
341+ if (toggle) {
342+ toggle.toggleHandler = toggleHandler;
343+ toggle.enable = enable;
344+ } else {
345+ extensionsToToggle.push({ name, toggleHandler, enable });
346+ }
347+
348+ toggleContainer
349+ .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
350+ .prop('checked', enable)
351+ .toggleClass('toggle_enable', !enable)
352+ .toggleClass('toggle_disable', enable)
353+ .toggleClass('checkbox_disabled', !enable);
354+ }
355+ }
356+
357+ return extensionsToToggle;
358+}
359+
360+/**
303361 * Enables an extension by name.
304362 * @param {string} name Extension name
305363 * @param {boolean} [reload=true] If true, reload the page after enabling the extension
@@ -332,6 +390,21 @@ export async function disableExtension(name, reload = true) {
332390}
333391
334392/**
393+ * Finds an extension by name, allowing omission of the "third-party/" prefix.
394+ *
395+ * @param {string} name - The name of the extension to find
396+ * @returns {{name: string, enabled: boolean}|null} Object with name and enabled properties, or null if not found
397+ */
398+export function findExtension(name) {
399+ const internalExtensionName = extensionNames.find(extName => {
400+ return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
401+ });
402+ if (!internalExtensionName) return null;
403+ const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
404+ return { name: internalExtensionName, enabled: isEnabled };
405+}
406+
407+/**
335408 * Loads manifest.json files for extensions.
336409 * @param {string[]} names Array of extension names
337410 * @returns {Promise<Record<string, object>>} Object with extension names as keys and their manifests as values
@@ -839,8 +912,15 @@ async function showExtensionsDetails() {
839912 await oldPopup.completeCancelled();
840913 }
841914 const htmlErrors = getExtensionLoadErrorsHtml();
842915 const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">' + t`Built-in Extensions:` + '</h3></div>');
843- const htmlExternal = $('<div class="marginBot10"><h3 class="textAlignCenter">' + t`Installed Extensions:` + '</h3></div>');
916+
917+ const htmlExternal = $(`<div class="marginBot10">
918+ <div class="flex-container alignitemscenter spaceBetween flexnowrap marginBot10">
919+ <h3 class="margin0">${t`Installed Extensions:`}</h3>
920+ <div class="flex-container third_party_toolbar"></div>
921+ </div>
922+ </div>`);
923+
844924 const htmlLoading = $(`<div class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5">
845925 <i class="fa-solid fa-spinner fa-spin"></i>
846926 <span>` + t`Loading third-party extensions... Please wait...` + `</span>
@@ -852,6 +932,7 @@ async function showExtensionsDetails() {
852932 const sortByName = accountStorage.getItem(sortOrderKey) === 'true';
853933 const sortFn = sortByName ? sortManifestsByName : sortManifestsByOrder;
854934 const extensions = Object.entries(manifests).sort((a, b) => sortFn(a[1], b[1])).map(getExtensionData);
935+ let extensionsToToggle = [];
855936
856937 extensions.forEach(value => {
857938 const { isExternal, extensionHtml } = value;
@@ -886,6 +967,54 @@ async function showExtensionsDetails() {
886967 updateEnabledOnlyButton.textContent = t`Update enabled`;
887968 updateEnabledOnlyButton.addEventListener('click', () => updateAction(false));
888969
970+ const toggleAllExtensionsButton = document.createElement('div');
971+ toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon');
972+ toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`;
973+ toggleAllExtensionsButton.innerHTML = `
974+ <span>${t`Toggle extensions`}</span>
975+ <div class="fa-solid fa-circle-info opacity50p"></div>
976+ `;
977+
978+ const restoreBulkToggledExtensionsButton = document.createElement('div');
979+ restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone');
980+ restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`;
981+
982+ toggleAllExtensionsButton.addEventListener('click', () => {
983+ extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal);
984+
985+ for (const extension of extensionsToToggle) {
986+ const { name } = extension;
987+
988+ htmlExternal
989+ .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
990+ .off('click')
991+ .one('click', () => {
992+ extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name);
993+ });
994+ }
995+
996+ const restoreButtonHandler = extensionsToToggle.length > 0 ? 'remove' : 'add';
997+
998+ restoreBulkToggledExtensionsButton.classList[restoreButtonHandler]('displayNone');
999+ });
1000+
1001+ restoreBulkToggledExtensionsButton.addEventListener('click', () => {
1002+ for (const extension of extensionsToToggle) {
1003+ const { name } = extension;
1004+ const isDisabled = extension_settings.disabledExtensions.includes(name);
1005+
1006+ htmlExternal
1007+ .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
1008+ .prop('checked', !isDisabled)
1009+ .toggleClass('toggle_enable', isDisabled)
1010+ .toggleClass('toggle_disable', !isDisabled)
1011+ .toggleClass('checkbox_disabled', isDisabled);
1012+ }
1013+
1014+ extensionsToToggle = [];
1015+ restoreBulkToggledExtensionsButton.classList.add('displayNone');
1016+ });
1017+
8891018 const flexExpander = document.createElement('div');
8901019 flexExpander.classList.add('expander');
8911020
@@ -899,6 +1028,7 @@ async function showExtensionsDetails() {
8991028 });
9001029
9011030 toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton);
1031+ htmlExternal.find('.third_party_toolbar').append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton);
9021032 html.prepend(toolbar);
9031033 }
9041034
@@ -914,6 +1044,24 @@ async function showExtensionsDetails() {
9141044 if (waitingForSave) {
9151045 return false;
9161046 }
1047+
1048+ for (const extension of extensionsToToggle) {
1049+ const { name, toggleHandler, enable } = extension;
1050+ const isDisabled = extension_settings.disabledExtensions.includes(name);
1051+
1052+ try {
1053+ if (isDisabled && !enable) continue;
1054+ if (!isDisabled && enable) continue;
1055+
1056+ requiresReload = true;
1057+
1058+ await toggleHandler(name, false);
1059+ } catch (error) {
1060+ console.error(`Could not toggle extension ${name}:`, error);
1061+ toastr.error(t`Could not toggle extension ${name}. See console for details.`);
1062+ }
1063+ }
1064+
9171065 if (stateChanged) {
9181066 waitingForSave = true;
9191067 const toast = toastr.info(t`The page will be reloaded shortly...`, t`Extensions state changed`);
@@ -922,6 +1070,7 @@ async function showExtensionsDetails() {
9221070 waitingForSave = false;
9231071 requiresReload = true;
9241072 }
1073+
9251074 return true;
9261075 },
9271076 });
public/scripts/extensions/assets/index.js+15 -15
@@ -103,10 +103,10 @@ async function downloadAssetsList(url) {
103103
104104 for (const i of json) {
105105 //console.log(DEBUG_PREFIX,i)
106106 if (availableAssets[i['.type']] === undefined)
107107 availableAssets[i['.type']] = [];
108108
109109 availableAssets[i['.type']].push(i);
110110 }
111111
112112 console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
@@ -139,7 +139,7 @@ async function downloadAssetsList(url) {
139139 assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
140140 }
141141
142142 for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['.name'].localeCompare(b['.name']))) {
143143 const i = availableAssets[assetType].indexOf(asset);
144144 const elemId = `assets_install_${assetType}_${i}`;
145145 let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
@@ -149,13 +149,13 @@ async function downloadAssetsList(url) {
149149 //if (DEBUG_TONY_SAMA_FORK_MODE)
150150 // asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG
151151
152152 console.debug(DEBUG_PREFIX, 'Checking asset', asset['.id'], asset['.url']);
153153
154154 const assetInstall = async function () {
155155 element.off('click');
156156 label.removeClass('fa-download');
157157 this.classList.add('asset-download-button-loading');
158158 await installAsset(asset['.url'], assetType, asset['.id']);
159159 label.addClass('fa-check');
160160 this.classList.remove('asset-download-button-loading');
161161 element.on('click', assetDelete);
@@ -173,11 +173,11 @@ async function downloadAssetsList(url) {
173173 const assetDelete = async function () {
174174 if (assetType === 'character') {
175175 toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
176176 await executeSlashCommandsWithOptions(`/go ${asset['.id']}`);
177177 return;
178178 }
179179 element.off('click');
180180 await deleteAsset(assetType, asset['.id']);
181181 label.removeClass('fa-check');
182182 label.removeClass('redOverlayGlow');
183183 label.removeClass('fa-trash');
@@ -186,7 +186,7 @@ async function downloadAssetsList(url) {
186186 element.on('click', assetInstall);
187187 };
188188
189189 if (isAssetInstalled(assetType, asset['.id'])) {
190190 console.debug(DEBUG_PREFIX, 'installed, checked');
191191 label.toggleClass('fa-download');
192192 label.toggleClass('fa-check');
@@ -207,14 +207,14 @@ async function downloadAssetsList(url) {
207207 element.on('click', assetInstall);
208208 }
209209
210210 console.debug(DEBUG_PREFIX, 'Created element for ', asset['.id']);
211211
212212 const displayName = DOMPurify.sanitize(asset['.name'] || asset['.id']);
213213 const description = DOMPurify.sanitize(asset['.description'] || '');
214214 const url = isValidUrl(asset['.url']) ? asset['.url'] : '';
215215 const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
216216 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
217217 const toolTag = assetType === 'extension' && asset['.tool'];
218218 const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
219219
220220 const assetBlock = $('<i></i>')
@@ -246,7 +246,7 @@ async function downloadAssetsList(url) {
246246 if (asset.highlight) {
247247 assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>');
248248 }
249249 assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset['.url']}" alt="${displayName}"></div>`);
250250 }
251251
252252 assetBlock.addClass('asset-block');
public/scripts/extensions/caption/index.js+11 -5
@@ -204,7 +204,7 @@ async function sendCaptionedMessage(caption, image, mimeType) {
204204 inline_image: !!extension_settings.caption.show_in_chat,
205205 },
206206 };
207207 chat_metadata['.tainted'] = true;
208208 context.chat.push(message);
209209 const messageId = context.chat.length - 1;
210210 await eventSource.emit(event_types.MESSAGE_SENT, messageId);
@@ -489,6 +489,8 @@ jQuery(async function () {
489489 'vertexai': SECRET_KEYS.VERTEXAI,
490490 'anthropic': SECRET_KEYS.CLAUDE,
491491 'xai': SECRET_KEYS.XAI,
492+ 'zai': SECRET_KEYS.ZAI,
493+ 'moonshot': SECRET_KEYS.MOONSHOT,
492494 };
493495
494496 if (reverseProxyApis[api]) {
@@ -502,11 +504,10 @@ jQuery(async function () {
502504 'groq': SECRET_KEYS.GROQ,
503505 'cohere': SECRET_KEYS.COHERE,
504506 'aimlapi': SECRET_KEYS.AIMLAPI,
505- 'moonshot': SECRET_KEYS.MOONSHOT,
506507 'nanogpt': SECRET_KEYS.NANOGPT,
507508 'chutes': SECRET_KEYS.CHUTES,
508509 'electronhub': SECRET_KEYS.ELECTRONHUB,
509510 'zaipollinations': SECRET_KEYS.ZAIPOLLINATIONS,
510511 };
511512
512513 if (chatCompletionApis[api] && secret_state[chatCompletionApis[api]]) {
@@ -530,7 +531,7 @@ jQuery(async function () {
530531 }
531532
532533 // Custom API doesn't need additional checks
533534 if (api === 'custom' || api === 'pollinations') {
534535 return true;
535536 }
536537 }
@@ -602,7 +603,7 @@ jQuery(async function () {
602603 const modelIds = await response.json();
603604 if (Array.isArray(modelIds) && modelIds.length > 0) {
604605 modelIds.sort().forEach((modelId) => {
605606 if (!modelId || typeof modelId !== 'string' || options.some(o => o.value === modelId && o.dataset.type === api)) {
606607 return;
607608 }
608609 const option = document.createElement('option');
@@ -622,6 +623,7 @@ jQuery(async function () {
622623 await processEndpoint('electronhub', '/api/backends/chat-completions/multimodal-models/electronhub');
623624 await processEndpoint('mistral', '/api/backends/chat-completions/multimodal-models/mistral');
624625 await processEndpoint('xai', '/api/backends/chat-completions/multimodal-models/xai');
626+ await processEndpoint('moonshot', '/api/backends/chat-completions/multimodal-models/moonshot');
625627 }
626628
627629 await addSettings();
@@ -699,6 +701,10 @@ jQuery(async function () {
699701 extension_settings.caption.ollama_custom_model = String($('#caption_ollama_custom_model').val()).trim();
700702 saveSettingsDebounced();
701703 });
704+ $('#caption_custom_model').val(extension_settings.caption.custom_model || '').on('input', () => {
705+ extension_settings.caption.custom_model = String($('#caption_custom_model').val()).trim();
706+ saveSettingsDebounced();
707+ });
702708 $('#caption_refresh_models').on('click', async () => {
703709 extension_settings.caption.multimodal_model = '';
704710 await switchMultimodalBlocks();
public/scripts/extensions/caption/settings.html+11 -5
@@ -49,13 +49,10 @@
4949 </div>
5050 </label>
5151 <select id="caption_multimodal_model" class="flex1 text_pole">
5252 <!-- AI/ML API, OpenRouter, Pollinations, NanoGPT, Mistral, xAI, Moonshot are added externally by JavaScript -->
5353 <option data-type="cohere" value="c4ai-aya-vision-8b">c4ai-aya-vision-8b</option>
5454 <option data-type="cohere" value="c4ai-aya-vision-32b">c4ai-aya-vision-32b</option>
5555 <option data-type="cohere" value="command-a-vision-07-2025">command-a-vision-07-2025</option>
56- <option data-type="moonshot" value="moonshot-v1-8k-vision-preview">moonshot-v1-8k-vision-preview</option>
57- <option data-type="moonshot" value="moonshot-v1-32k-vision-preview">moonshot-v1-32k-vision-preview</option>
58- <option data-type="moonshot" value="moonshot-v1-128k-vision-preview">moonshot-v1-128k-vision-preview</option>
5956 <option data-type="openai" value="gpt-5.2">gpt-5.2</option>
6057 <option data-type="openai" value="gpt-5.2-2025-12-11">gpt-5.2-2025-12-11</option>
6158 <option data-type="openai" value="gpt-5.2-chat-latest">gpt-5.2-chat-latest</option>
@@ -89,6 +86,7 @@
8986 <option data-type="openai" value="o4-mini-2025-04-16">o4-mini-2025-04-16</option>
9087 <option data-type="openai" value="gpt-4.5-preview">gpt-4.5-preview</option>
9188 <option data-type="openai" value="gpt-4.5-preview-2025-02-27">gpt-4.5-preview-2025-02-27</option>
89+ <option data-type="anthropic" value="claude-opus-4-6">claude-opus-4-6</option>
9290 <option data-type="anthropic" value="claude-opus-4-5">claude-opus-4-5</option>
9391 <option data-type="anthropic" value="claude-opus-4-5-20251101">claude-opus-4-5-20251101</option>
9492 <option data-type="anthropic" value="claude-sonnet-4-5">claude-sonnet-4-5</option>
@@ -177,8 +175,16 @@
177175 <option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
178176 <option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>
179177 <option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option>
178+ <option data-type="custom" value="custom_custom" data-i18n="[Custom model]">[Custom model]</option>
180179 </select>
181180 </div>
181+ <div data-type="custom">
182+ <label for="caption_custom_model">
183+ <span data-i18n="Model Id">Model Id</span>
184+ <small data-i18n="(for [Custom model] option)">(for [Custom model] option)</small>
185+ </label>
186+ <input id="caption_custom_model" class="text_pole" type="text" placeholder="e.g. gpt-4o" />
187+ </div>
182188 <div data-type="ollama">
183189 <div>
184190 The model must be downloaded first! Do it with the <code>ollama pull</code> command or <a href="#" id="caption_ollama_pull">click here</a>.
@@ -191,7 +197,7 @@
191197 <input id="caption_ollama_custom_model" class="text_pole" type="text" placeholder="e.g. gemma3:latest" />
192198 </div>
193199 </div>
194200 <label data-type="openai,anthropic,google,vertexai,mistral,xai,zai,moonshot" class="checkbox_label flexBasis100p" for="caption_allow_reverse_proxy" title="Allow using reverse proxy if defined and valid.">
195201 <input id="caption_allow_reverse_proxy" type="checkbox" class="checkbox">
196202 <span data-i18n="Allow reverse proxy">Allow reverse proxy</span>
197203 </label>
public/scripts/extensions/memory/index.js+10 -6
@@ -437,9 +437,13 @@ async function onChatEvent() {
437437
438438 const context = getContext();
439439 const chat = context.chat;
440+ // Chat can't be empty.
441+ if (chat.length === 0) return;
442+
443+ const lastMessage = chat[chat.length - 1];
440444
441445 // No new messages - do nothing
442446 if (chat.length === 0 || (lastMessageId === chat.length && getStringHash(chat[chat.length - 1]lastMessage.mes) === lastMessageHash)) {
443447 return;
444448 }
445449
@@ -451,18 +455,18 @@ async function onChatEvent() {
451455
452456 // Message has been edited / regenerated - delete the saved memory
453457 if (chat.length
454458 && chat[chat.length - 1]lastMessage.extra
455459 && chat[chat.length - 1]lastMessage.extra.memory
456460 && lastMessageId === chat.length
457461 && getStringHash(chat[chat.length - 1]lastMessage.mes) !== lastMessageHash) {
458462 delete chat[chat.length - 1]lastMessage.extra.memory;
459463 }
460464
461465 summarizeChat(context)
462466 .catch(console.error)
463467 .finally(() => {
464468 lastMessageId = context.chat?.length ?? null;
465469 lastMessageHash = getStringHash((context.chat.length && context.chat[context.chat.length - 1]['.mes']) ?? '');
466470 });
467471}
468472
public/scripts/extensions/quick-reply/index.js+1 -1
@@ -185,7 +185,7 @@ const init = async () => {
185185 buttons.show();
186186 settings.onSave = ()=>buttons.refresh();
187187
188188 window['globalThis.executeQuickReplyByName'] = async(name, args = {}, options = {}) => {
189189 let qr = [
190190 ...settings.config.setList,
191191 ...(settings.chatConfig?.setList ?? []),
public/scripts/extensions/quick-reply/src/SlashCommandHandler.js+1 -1
@@ -77,7 +77,7 @@ export class SlashCommandHandler {
7777 },
7878 };
7979
8080 window['globalThis.qrEnumProviderExecutables'] = localEnumProviders.qrExecutables;
8181
8282 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr',
8383 callback: (_, value) => this.executeQuickReplyByIndex(Number(value)),
public/scripts/extensions/shared.js+17 -2
@@ -15,7 +15,7 @@ import { createThumbnail, isValidUrl } from '../utils.js';
1515 */
1616export async function getMultimodalCaption(base64Img, prompt) {
1717 const useReverseProxy =
1818 (['openai', 'anthropic', 'google', 'mistral', 'vertexai', 'xai', 'zai', 'moonshot'].includes(extension_settings.caption.multimodal_api))
1919 && extension_settings.caption.allow_reverse_proxy
2020 && oai_settings.reverse_proxy
2121 && isValidUrl(oai_settings.reverse_proxy);
@@ -108,8 +108,15 @@ export async function getMultimodalCaption(base64Img, prompt) {
108108 }
109109
110110 if (isCustom) {
111+ if (extension_settings.caption.multimodal_model === 'custom_current') {
112+ requestBody.model = oai_settings.custom_model || '';
113+ }
114+
115+ if (extension_settings.caption.multimodal_model === 'custom_custom') {
116+ requestBody.model = extension_settings.caption.custom_model || '';
117+ }
118+
111119 requestBody.server_url = oai_settings.custom_url;
112- requestBody.model = oai_settings.custom_model || 'gpt-4-turbo';
113120 requestBody.custom_include_headers = oai_settings.custom_include_headers;
114121 requestBody.custom_include_body = oai_settings.custom_include_body;
115122 requestBody.custom_exclude_body = oai_settings.custom_exclude_body;
@@ -245,6 +252,10 @@ function throwIfInvalidModel(useReverseProxy) {
245252 throw new Error('Custom API URL is not set.');
246253 }
247254
255+ if (multimodalApi === 'custom' && multimodalModel === 'custom_custom' && !extension_settings.caption.custom_model) {
256+ throw new Error('Custom OpenAI-compatible Model ID is not set.');
257+ }
258+
248259 if (multimodalApi === 'aimlapi' && !secret_state[SECRET_KEYS.AIMLAPI]) {
249260 throw new Error('AI/ML API key is not set.');
250261 }
@@ -268,6 +279,10 @@ function throwIfInvalidModel(useReverseProxy) {
268279 if (multimodalApi === 'zai' && !secret_state[SECRET_KEYS.ZAI]) {
269280 throw new Error('Z.AI API key is not set.');
270281 }
282+
283+ if (multimodalApi === 'pollinations' && !secret_state[SECRET_KEYS.POLLINATIONS]) {
284+ throw new Error('Pollinations API key is not set.');
285+ }
271286}
272287
273288/**
public/scripts/extensions/stable-diffusion/index.js+507 -69
@@ -69,10 +69,17 @@ const MODULE_NAME = 'sd';
6969// This is a 1x1 transparent PNG
7070const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
7171const CUSTOM_STOP_EVENT = 'sd_stop_generation';
72+
73+// Generation tracking for status indicator
74+let activeGenerations = 0;
75+/** @type {JQuery<HTMLElement>|null} */
76+let generationToast = null;
77+
7278const sources = {
7379 extras: 'extras',
7480 horde: 'horde',
7581 auto: 'auto',
82+ sdcpp: 'sdcpp',
7683 novel: 'novel',
7784 vlad: 'vlad',
7885 openai: 'openai',
@@ -277,6 +284,7 @@ const defaultSettings = {
277284 snap: false,
278285 free_extend: false,
279286 function_tool: false,
287+ minimal_prompt_processing: false,
280288
281289 prompts: promptTemplates,
282290
@@ -284,6 +292,9 @@ const defaultSettings = {
284292 auto_url: 'http://localhost:7860',
285293 auto_auth: '',
286294
295+ // stable-diffusion.cpp settings
296+ sdcpp_url: 'http://127.0.0.1:1234',
297+
287298 vlad_url: 'http://localhost:7860',
288299 vlad_auth: '',
289300
@@ -320,6 +331,7 @@ const defaultSettings = {
320331 // OpenAI settings
321332 openai_style: 'vivid',
322333 openai_quality: 'standard',
334+ openai_quality_gpt: 'auto',
323335 openai_duration: '8',
324336
325337 style: 'Default',
@@ -425,7 +437,7 @@ function processTriggers(chat, _, abort, type) {
425437 }
426438}
427439
428440window['globalThis.SD_ProcessTriggers'] = processTriggers;
429441
430442function getSdRequestBody() {
431443 switch (extension_settings.sd.source) {
@@ -521,6 +533,7 @@ async function loadSettings() {
521533 $('#sd_multimodal_captioning').prop('checked', extension_settings.sd.multimodal_captioning);
522534 $('#sd_auto_url').val(extension_settings.sd.auto_url);
523535 $('#sd_auto_auth').val(extension_settings.sd.auto_auth);
536+ $('#sd_sdcpp_url').val(extension_settings.sd.sdcpp_url);
524537 $('#sd_vlad_url').val(extension_settings.sd.vlad_url);
525538 $('#sd_vlad_auth').val(extension_settings.sd.vlad_auth);
526539 $('#sd_drawthings_url').val(extension_settings.sd.drawthings_url);
@@ -528,12 +541,14 @@ async function loadSettings() {
528541 $('#sd_interactive_mode').prop('checked', extension_settings.sd.interactive_mode);
529542 $('#sd_openai_style').val(extension_settings.sd.openai_style);
530543 $('#sd_openai_quality').val(extension_settings.sd.openai_quality);
544+ $('#sd_openai_quality_gpt').val(extension_settings.sd.openai_quality_gpt);
531545 $('#sd_openai_duration').val(extension_settings.sd.openai_duration);
532546 $('#sd_comfy_type').val(extension_settings.sd.comfy_type);
533547 $('#sd_comfy_url').val(extension_settings.sd.comfy_url);
534548 $('#sd_comfy_prompt').val(extension_settings.sd.comfy_prompt);
535549 $('#sd_comfy_runpod_url').val(extension_settings.sd.comfy_runpod_url);
536550 $('#sd_snap').prop('checked', extension_settings.sd.snap);
551+ $('#sd_minimal_prompt_processing').prop('checked', extension_settings.sd.minimal_prompt_processing);
537552 $('#sd_clip_skip').val(extension_settings.sd.clip_skip);
538553 $('#sd_clip_skip_value').val(extension_settings.sd.clip_skip);
539554 $('#sd_seed').val(extension_settings.sd.seed);
@@ -656,6 +671,11 @@ function onSnapInput() {
656671 saveSettingsDebounced();
657672}
658673
674+function onMinimalPromptProcessing() {
675+ extension_settings.sd.minimal_prompt_processing = !!$(this).prop('checked');
676+ saveSettingsDebounced();
677+}
678+
659679function onStyleSelect() {
660680 const selectedStyle = String($('#sd_style').find(':selected').val());
661681 const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
@@ -708,7 +728,8 @@ async function onDeleteStyleClick() {
708728}
709729
710730async function onSaveStyleClick() {
711- const userInput = await callGenericPopup('Enter style name:', POPUP_TYPE.INPUT);
731+ const selectedStyle = extension_settings.sd.style || '';
732+ const userInput = await callGenericPopup(t`Enter style name:`, POPUP_TYPE.INPUT, selectedStyle);
712733
713734 if (!userInput) {
714735 return;
@@ -744,6 +765,48 @@ async function onSaveStyleClick() {
744765 saveSettingsDebounced();
745766}
746767
768+async function onRenameStyleClick() {
769+ const selectedStyle = extension_settings.sd.style;
770+ const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
771+
772+ if (!styleObject) {
773+ return;
774+ }
775+
776+ const newName = await callGenericPopup(t`Enter new style name:`, POPUP_TYPE.INPUT, selectedStyle);
777+
778+ if (!newName) {
779+ return;
780+ }
781+
782+ const name = String(newName).trim();
783+
784+ if (name === selectedStyle) {
785+ return;
786+ }
787+
788+ const existingStyle = extension_settings.sd.styles.find(x => x.name === name);
789+
790+ if (existingStyle) {
791+ toastr.error(t`A style with that name already exists`);
792+ return;
793+ }
794+
795+ styleObject.name = name;
796+ extension_settings.sd.style = name;
797+
798+ $('#sd_style').empty();
799+ for (const style of extension_settings.sd.styles) {
800+ const option = document.createElement('option');
801+ option.value = style.name;
802+ option.text = style.name;
803+ option.selected = style.name === extension_settings.sd.style;
804+ $('#sd_style').append(option);
805+ }
806+
807+ saveSettingsDebounced();
808+}
809+
747810/**
748811 * Modifies prompt based on user inputs.
749812 * @param {string} prompt Prompt to refine
@@ -977,6 +1040,13 @@ const resolutionOptions = {
9771040 sd_res_1024x1536: { width: 1024, height: 1536, name: '1024x1536 (2:3, ChatGPT)' },
9781041 sd_res_1024x1792: { width: 1024, height: 1792, name: '1024x1792 (4:7, DALL-E)' },
9791042 sd_res_1792x1024: { width: 1792, height: 1024, name: '1792x1024 (7:4, DALL-E)' },
1043+ sd_res_1280x1280: { width: 1280, height: 1280, name: '1280x1280 (1:1, Z.AI)' },
1044+ sd_res_1568x1056: { width: 1568, height: 1056, name: '1568x1056 (3:2, Z.AI)' },
1045+ sd_res_1056x1568: { width: 1056, height: 1568, name: '1056x1568 (2:3, Z.AI)' },
1046+ sd_res_1472x1088: { width: 1472, height: 1088, name: '1472x1088 (4:3, Z.AI)' },
1047+ sd_res_1088x1472: { width: 1088, height: 1472, name: '1088x1472 (3:4, Z.AI)' },
1048+ sd_res_1728x960: { width: 1728, height: 960, name: '1728x960 (16:9, Z.AI)' },
1049+ sd_res_960x1728: { width: 960, height: 1728, name: '960x1728 (9:16, Z.AI)' },
9801050};
9811051
9821052function onResolutionChange() {
@@ -1141,6 +1211,11 @@ function onAutoAuthInput() {
11411211 saveSettingsDebounced();
11421212}
11431213
1214+function onSdcppUrlInput() {
1215+ extension_settings.sd.sdcpp_url = $('#sd_sdcpp_url').val();
1216+ saveSettingsDebounced();
1217+}
1218+
11441219function onVladUrlInput() {
11451220 extension_settings.sd.vlad_url = $('#sd_vlad_url').val();
11461221 saveSettingsDebounced();
@@ -1249,6 +1324,29 @@ async function validateAutoUrl() {
12491324 }
12501325}
12511326
1327+async function validateSdcppUrl() {
1328+ try {
1329+ if (!extension_settings.sd.sdcpp_url) {
1330+ throw new Error('URL is not set.');
1331+ }
1332+
1333+ const result = await fetch('/api/sd/sdcpp/ping', {
1334+ method: 'POST',
1335+ headers: getRequestHeaders(),
1336+ body: JSON.stringify({ url: extension_settings.sd.sdcpp_url }),
1337+ });
1338+
1339+ if (!result.ok) {
1340+ throw new Error('stable-diffusion.cpp server returned an error.');
1341+ }
1342+
1343+ await loadSettingOptions();
1344+ toastr.success('stable-diffusion.cpp server connected.');
1345+ } catch (error) {
1346+ toastr.error(`Could not validate stable-diffusion.cpp server: ${error.message}`);
1347+ }
1348+}
1349+
12521350async function validateDrawthingsUrl() {
12531351 try {
12541352 if (!extension_settings.sd.drawthings_url) {
@@ -1542,6 +1640,9 @@ async function loadSamplers() {
15421640 case sources.auto:
15431641 samplers = await loadAutoSamplers();
15441642 break;
1643+ case sources.sdcpp:
1644+ samplers = await loadSdcppSamplers();
1645+ break;
15451646 case sources.drawthings:
15461647 samplers = await loadDrawthingsSamplers();
15471648 break;
@@ -1667,6 +1768,11 @@ async function loadAutoSamplers() {
16671768 }
16681769}
16691770
1771+async function loadSdcppSamplers() {
1772+ // The sdcpp server does not provide an API for samplers, so we return the known list.
1773+ return ['euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd'];
1774+}
1775+
16701776async function loadDrawthingsSamplers() {
16711777 // The app developer doesn't provide an API to get these yet
16721778 return [
@@ -1756,6 +1862,9 @@ async function loadModels() {
17561862 case sources.auto:
17571863 models = await loadAutoModels();
17581864 break;
1865+ case sources.sdcpp:
1866+ models = [{ value: '', text: 'N/A' }];
1867+ break;
17591868 case sources.drawthings:
17601869 models = await loadDrawthingsModels();
17611870 break;
@@ -1850,7 +1959,7 @@ function switchModelSpecificControls(modelId) {
18501959
18511960 modelControls.each(function () {
18521961 const models = String($(this).attr('data-sd-model') || '').split(',').map(m => m.trim());
18531962 $(this).toggle(models.includessome(m => modelId.includes(m)));
18541963 });
18551964}
18561965
@@ -1940,6 +2049,8 @@ async function loadXAIModels() {
19402049}
19412050
19422051async function loadPollinationsModels() {
2052+ $('#sd_pollinations_key').toggleClass('success', !!secret_state[SECRET_KEYS.POLLINATIONS]);
2053+
19432054 const result = await fetch('/api/sd/pollinations/models', {
19442055 method: 'POST',
19452056 headers: getRequestHeaders({ omitContentType: true }),
@@ -2169,6 +2280,7 @@ async function loadOpenAiModels() {
21692280 { value: 'gpt-image-1.5', text: 'gpt-image-1.5' },
21702281 { value: 'gpt-image-1-mini', text: 'gpt-image-1-mini' },
21712282 { value: 'gpt-image-1', text: 'gpt-image-1' },
2283+ { value: 'chatgpt-image-latest', text: 'chatgpt-image-latest' },
21722284 { value: 'dall-e-3', text: 'dall-e-3' },
21732285 { value: 'dall-e-2', text: 'dall-e-2' },
21742286 { value: 'sora-2', text: 'sora-2' },
@@ -2294,7 +2406,12 @@ async function loadGoogleModels() {
22942406}
22952407
22962408async function loadZaiModels() {
2297- return ['cogview-4-250304'].map(name => ({ value: name, text: name }));
2409+ return [
2410+ { value: 'glm-image', text: 'GLM-Image' },
2411+ { value: 'cogview-4-250304', text: 'CogView-4' },
2412+ { value: 'cogvideox-3', text: 'CogVideoX-3' },
2413+ { value: 'viduq1-text', text: 'Viduq1-Text' },
2414+ ];
22982415}
22992416
23002417async function loadOpenRouterModels() {
@@ -2356,6 +2473,9 @@ async function loadSchedulers() {
23562473 case sources.auto:
23572474 schedulers = await getAutoRemoteSchedulers();
23582475 break;
2476+ case sources.sdcpp:
2477+ schedulers = await loadSdcppSchedulers();
2478+ break;
23592479 case sources.novel:
23602480 schedulers = loadNovelSchedulers();
23612481 break;
@@ -2454,6 +2574,11 @@ async function loadComfySchedulers() {
24542574 }
24552575}
24562576
2577+async function loadSdcppSchedulers() {
2578+ // The sdcpp server does not provide an API for schedulers, so we return the known list.
2579+ return ['discrete', 'karras', 'exponential', 'ays', 'gits', 'smoothstep', 'sgm_uniform', 'simple', 'kl_optimal', 'lcm'];
2580+}
2581+
24572582async function loadVaes() {
24582583 $('#sd_vae').empty();
24592584 let vaes = [];
@@ -2468,6 +2593,9 @@ async function loadVaes() {
24682593 case sources.auto:
24692594 vaes = await loadAutoVaes();
24702595 break;
2596+ case sources.sdcpp:
2597+ vaes = ['N/A'];
2598+ break;
24712599 case sources.novel:
24722600 vaes = ['N/A'];
24732601 break;
@@ -2657,6 +2785,15 @@ function processReply(str) {
26572785 return '';
26582786 }
26592787
2788+ if (extension_settings.sd.minimal_prompt_processing) {
2789+ // Minimal prompt processing
2790+ // JSON and similar should be preserved
2791+ str = str.normalize('NFD');
2792+ str = str.replace(/\s+/g, ' '); // Collapse multiple whitespaces into one
2793+ str = str.trim();
2794+ return str;
2795+ }
2796+
26602797 str = str.replaceAll('"', '');
26612798 str = str.replaceAll('“', '');
26622799 str = str.replaceAll('\n', ', ');
@@ -2728,6 +2865,62 @@ function ensureSelectionExists(setting, selector) {
27282865}
27292866
27302867/**
2868+ * Updates the generation status indicator based on active generation count.
2869+ * Shows/hides various UI indicators to inform user of background image generation.
2870+ */
2871+function updateGenerationIndicator() {
2872+ if (activeGenerations > 0) {
2873+ const countText = activeGenerations > 1 ? ` (${activeGenerations})` : '';
2874+ const toastText = `<i class="fa-solid fa-spinner fa-spin"></i> ${t`Generating image`}${countText}...`;
2875+
2876+ // Show persistent toast if not already showing
2877+ if (!generationToast) {
2878+ generationToast = toastr.info(
2879+ toastText,
2880+ 'Image Generation',
2881+ {
2882+ timeOut: 0,
2883+ extendedTimeOut: 0,
2884+ tapToDismiss: true,
2885+ escapeHtml: false,
2886+ onHidden: () => {
2887+ generationToast = null;
2888+ },
2889+ },
2890+ );
2891+ } else if (activeGenerations > 1) {
2892+ // Update count in existing toast
2893+ const toastMessage = $(generationToast).find('.toast-message');
2894+ if (toastMessage.length) {
2895+ toastMessage.html(toastText);
2896+ }
2897+ }
2898+ } else {
2899+ // Hide toast when done
2900+ if (generationToast) {
2901+ toastr.clear(generationToast);
2902+ generationToast = null;
2903+ }
2904+ }
2905+}
2906+
2907+/**
2908+ * Increments the active generation counter and updates indicators.
2909+ */
2910+function startGenerationTracking() {
2911+ activeGenerations++;
2912+ updateGenerationIndicator();
2913+}
2914+
2915+/**
2916+ * Decrements the active generation counter and updates indicators.
2917+ */
2918+function endGenerationTracking() {
2919+ activeGenerations = Math.max(0, activeGenerations - 1);
2920+ updateGenerationIndicator();
2921+}
2922+
2923+/**
27312924 * Generates an image based on the given trigger word.
27322925 * @param {string} initiator The initiator of the image generation
27332926 * @param {Record<string, object>} args Command arguments
@@ -2801,6 +2994,9 @@ async function generatePicture(initiator, args, trigger, message, callback) {
28012994 await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);
28022995 prompt = eventData.prompt; // Allow extensions to modify the prompt
28032996
2997+ // Track this generation for status indicator
2998+ startGenerationTracking();
2999+ // Show stop button after prompt is ready (prompt generation uses separate abort mechanism)
28043000 $(stopButton).show();
28053001 eventSource.once(CUSTOM_STOP_EVENT, stopListener);
28063002
@@ -2811,6 +3007,13 @@ async function generatePicture(initiator, args, trigger, message, callback) {
28113007 // generate the image
28123008 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);
28133009 } catch (err) {
3010+ // Check if this was an intentional abort by user
3011+ if (abortController.signal.aborted) {
3012+ console.log('SD: Image generation aborted by user');
3013+ toastr.info('Image generation stopped.', 'Image Generation');
3014+ return;
3015+ }
3016+
28143017 console.trace(err);
28153018 // errors here are most likely due to text generation failure
28163019 // sendGenerationRequest mostly deals with its own errors
@@ -2823,6 +3026,7 @@ async function generatePicture(initiator, args, trigger, message, callback) {
28233026 $(stopButton).hide();
28243027 restoreOriginalDimensions(dimensions);
28253028 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
3029+ endGenerationTracking();
28263030 }
28273031
28283032 return imagePath;
@@ -3014,8 +3218,10 @@ function getUserAvatarUrl() {
30143218 * @returns {Promise<string>} - A promise that resolves when the prompt generation completes.
30153219 */
30163220async function generatePrompt(quietPrompt) {
3221+ const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation');
30173222 const reply = await generateQuietPrompt({ quietPrompt });
30183223 const processedReply = processReply(reply);
3224+ toastr.clear(toast);
30193225
30203226 if (!processedReply) {
30213227 toastr.error('Prompt generation produced no text. Make sure you\'re using a valid instruct template and try again', 'Image Generation');
@@ -3074,6 +3280,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
30743280 case sources.auto:
30753281 result = await generateAutoImage(prefixedPrompt, negativePrompt, signal);
30763282 break;
3283+ case sources.sdcpp:
3284+ result = await generateSdcppImage(prefixedPrompt, negativePrompt, signal);
3285+ break;
30773286 case sources.novel:
30783287 result = await generateNovelImage(prefixedPrompt, negativePrompt, signal);
30793288 break;
@@ -3140,6 +3349,13 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
31403349 throw new Error('Endpoint did not return image data.');
31413350 }
31423351 } catch (err) {
3352+ // Check if this was an intentional abort by user
3353+ if (signal?.aborted) {
3354+ console.log('SD: Image generation aborted by user');
3355+ toastr.info('Image generation stopped.', 'Image Generation');
3356+ return;
3357+ }
3358+
31433359 console.error('Image generation request error: ', err);
31443360 toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
31453361 return;
@@ -3215,7 +3431,7 @@ async function generatePollinationsImage(prompt, negativePrompt, signal) {
32153431
32163432 if (result.ok) {
32173433 const data = await result.json();
32183434 return { format: 'jpg'data?.format, data: data?.image };
32193435 } else {
32203436 const text = await result.text();
32213437 throw new Error(text);
@@ -3271,7 +3487,7 @@ async function generateExtrasImage(prompt, negativePrompt, signal) {
32713487 * Gets an aspect ratio for Stability that is the closest to the given width and height.
32723488 * @param {number} width Target width
32733489 * @param {number} height Target height
32743490 * @param {'google'|'stability'|'zai'} source Source of the request, used to determine aspect ratio
32753491 * @returns {string} Closest aspect ratio as a string
32763492 */
32773493function getClosestAspectRatio(width, height, source) {
@@ -3297,6 +3513,12 @@ function getClosestAspectRatio(width, height, source) {
32973513 '4:3': 4 / 3,
32983514 '3:4': 3 / 4,
32993515 };
3516+ case 'zai':
3517+ return {
3518+ '1:1': 1,
3519+ '16:9': 16 / 9,
3520+ '9:16': 9 / 16,
3521+ };
33003522 default:
33013523 console.warn(`Unknown source "${source}" for aspect ratio calculation.`);
33023524 return null;
@@ -3325,22 +3547,41 @@ function getClosestAspectRatio(width, height, source) {
33253547 * Get closest size for Electron Hub
33263548 * @param {number} width - The width of the image
33273549 * @param {number} height - The height of the image
3550+ * @param {string[]} sizes - Available sizes
33283551 * @returns {Promise<string>} - The closest size
33293552 */
33303553async function getClosestSize(width, height, sizes = []) {
3331- const response = await fetch('/api/sd/electronhub/sizes', {
3554+ const sizesData = [];
3332- method: 'POST',
3555+
3333- headers: getRequestHeaders(),
3556+ if (Array.isArray(sizes) && sizes.length > 0) {
3334- body: JSON.stringify({
3557+ sizesData.push(...sizes);
3335- model: extension_settings.sd.model,
3558+ } else if (extension_settings.sd.source === sources.electronhub) {
3336- }),
3559+ const response = await fetch('/api/sd/electronhub/sizes', {
3337- });
3560+ method: 'POST',
3338- if (!response.ok) {
3561+ headers: getRequestHeaders(),
3339- const text = await response.text();
3562+ body: JSON.stringify({
3340- throw new Error(text);
3563+ model: extension_settings.sd.model,
3564+ }),
3565+ });
3566+ if (!response.ok) {
3567+ const text = await response.text();
3568+ throw new Error(text);
3569+ }
3570+ const result = await response.json();
3571+ sizesData.push(...result.sizes);
3572+ } else {
3573+ return null;
3574+ }
3575+
3576+ const targetWidth = Number(width);
3577+ const targetHeight = Number(height);
3578+
3579+ if (isNaN(targetWidth) || isNaN(targetHeight)) {
3580+ return null;
33413581 }
3342- const result = await response.json();
3582+
33433583 const sizesDatatargetAspect = result.sizestargetWidth / targetHeight;
3584+ const targetResolution = targetWidth * targetHeight;
33443585
33453586 const closestSize = sizesData.reduce((closest, size) => {
33463587 if (!size || typeof size !== 'string') {
@@ -3353,16 +3594,14 @@ async function getClosestSize(width, height) {
33533594
33543595 const sizeWidth = Number(sizeParts[0]);
33553596 const sizeHeight = Number(sizeParts[1]);
3356- const targetWidth = Number(width);
3357- const targetHeight = Number(height);
33583597
33593598 if (isNaN(sizeWidth) || isNaN(sizeHeight) || isNaN(targetWidth) || isNaN(targetHeight)) {
33603599 return closest;
33613600 }
33623601
33633602 const sizeAreaaspectDiff = Math.abs((sizeWidth */ sizeHeight) - targetAspect) / targetAspect;
33643603 const targetArearesolutionDiff = targetWidthMath.abs(sizeWidth * targetHeightsizeHeight - targetResolution) / targetResolution;
33653604 const diff = Math.abs(sizeAreaaspectDiff -+ targetArea)resolutionDiff;
33663605
33673606 return diff < closest.diff ? { size, diff } : closest;
33683607 }, { size: null, diff: Infinity });
@@ -3532,6 +3771,55 @@ async function generateAutoImage(prompt, negativePrompt, signal) {
35323771}
35333772
35343773/**
3774+ * Generates an image using stable-diffusion.cpp server API.
3775+ *
3776+ * @param {string} prompt - The main instruction used to guide the image generation.
3777+ * @param {string} negativePrompt - The instruction used to restrict the image generation.
3778+ * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
3779+ * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
3780+ */
3781+async function generateSdcppImage(prompt, negativePrompt, signal) {
3782+ const payload = {
3783+ url: extension_settings.sd.sdcpp_url,
3784+ prompt: prompt,
3785+ negative_prompt: negativePrompt,
3786+ steps: extension_settings.sd.steps,
3787+ cfg_scale: extension_settings.sd.scale,
3788+ width: extension_settings.sd.width,
3789+ height: extension_settings.sd.height,
3790+ batch_size: 1,
3791+ seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
3792+ };
3793+
3794+ if (extension_settings.sd.sampler && extension_settings.sd.sampler !== 'N/A') {
3795+ payload.sampler_name = extension_settings.sd.sampler;
3796+ }
3797+
3798+ if (extension_settings.sd.scheduler && extension_settings.sd.scheduler !== 'N/A') {
3799+ payload.scheduler = extension_settings.sd.scheduler;
3800+ }
3801+
3802+ if (Number.isFinite(extension_settings.sd.clip_skip)) {
3803+ payload.clip_skip = extension_settings.sd.clip_skip;
3804+ }
3805+
3806+ const result = await fetch('/api/sd/sdcpp/generate', {
3807+ method: 'POST',
3808+ headers: getRequestHeaders(),
3809+ signal: signal,
3810+ body: JSON.stringify(payload),
3811+ });
3812+
3813+ if (result.ok) {
3814+ const data = await result.json();
3815+ return { format: 'png', data: data.images?.[0] };
3816+ } else {
3817+ const text = await result.text();
3818+ throw new Error(text);
3819+ }
3820+}
3821+
3822+/**
35353823 * Generates an image in Drawthings API using the provided prompt and configuration settings.
35363824 *
35373825 * @param {string} prompt - The main instruction used to guide the image generation.
@@ -3697,7 +3985,7 @@ async function generateOpenAiImage(prompt, signal) {
36973985
36983986 const isDalle2 = /dall-e-2/.test(extension_settings.sd.model);
36993987 const isDalle3 = /dall-e-3/.test(extension_settings.sd.model);
37003988 const isGptImg = /gpt-image-(1|latest)/.test(extension_settings.sd.model);
37013989 const isSora2 = /sora-2/.test(extension_settings.sd.model);
37023990
37033991 if (isDalle2 && prompt.length > dalle2PromptLimit) {
@@ -3770,7 +4058,7 @@ async function generateOpenAiImage(prompt, signal) {
37704058 model: extension_settings.sd.model,
37714059 size: `${width}x${height}`,
37724060 n: 1,
37734061 quality: isDalle3 ? extension_settings.sd.openai_quality : (isGptImg ? extension_settings.sd.openai_quality_gpt : undefined),
37744062 style: isDalle3 ? extension_settings.sd.openai_style : undefined,
37754063 response_format: isDalle2 || isDalle3 ? 'b64_json' : undefined,
37764064 moderation: isGptImg ? 'low' : undefined,
@@ -4242,38 +4530,77 @@ async function generateGoogleImage(prompt, negativePrompt, signal) {
42424530 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
42434531 */
42444532async function generateZaiImage(prompt, signal) {
4245- // Round width and height to nearest multiple of 16, and clamp to 512-2048 range
4533+ // Video generation models (CogVideoX, Viduq1)
4246- let width = clamp(Math.round(extension_settings.sd.width / 16) * 16, 512, 2048);
4534+ if (/(cogvideox|vidu)/.test(extension_settings.sd.model)) {
4247- let height = clamp(Math.round(extension_settings.sd.height / 16) * 16, 512, 2048);
4535+ const videoParams = {};
4248-
4536+ if (/cogvideox/.test(extension_settings.sd.model)) {
4249- // Make sure the pixel count does not exceed 2^21px
4537+ const cogVideoSizes = ['1280x720', '720x1280', '1024x1024', '1080x1920', '2048x1080', '3840x2160'];
4250- while ((width * height) > Math.pow(2, 21)) {
4538+ videoParams.quality = extension_settings.sd.openai_quality === 'hd' ? 'quality' : 'speed';
4251- if (width >= height) {
4539+ videoParams.size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height, cogVideoSizes);
4252- width -= 16;
4540+ }
4253- } else {
4541+ if (/vidu/.test(extension_settings.sd.model)) {
4254- height -= 16;
4542+ videoParams.aspect_ratio = getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'zai');
42554543 }
4256- }
42574544
42584545 const resultvideoResult = await fetch('/api/sd/zai/generate-video', {
42594546 method: 'POST',
42604547 headers: getRequestHeaders(),
42614548 signal: signal,
42624549 body: JSON.stringify({
42634550 prompt: prompt,
42644551 model: extension_settings.sd.model,
4265- quality: extension_settings.sd.openai_quality,
4552+ ...videoParams,
4266- size: `${width}x${height}`,
4553+ }),
42674554 }),;
4268- });
42694555
42704556 if (resultvideoResult.ok) {
42714557 const data = await resultvideoResult.json();
42724558 return { format: data.format, data: data.imagevideo };
42734559 }
42744560
42754561 const text = await resultvideoResult.text();
42764562 throw new Error(text);
4563+ } else {
4564+ // Image generation models (GLM-Image, CogView)
4565+ // GLM-Image requires multiples of 32, CogView requires multiples of 16
4566+ const isGlmImage = /glm-image/.test(extension_settings.sd.model);
4567+ const multiple = isGlmImage ? 32 : 16;
4568+
4569+ // Round width and height to nearest multiple and clamp to 512-2048 range
4570+ let width = clamp(Math.round(extension_settings.sd.width / multiple) * multiple, 512, 2048);
4571+ let height = clamp(Math.round(extension_settings.sd.height / multiple) * multiple, 512, 2048);
4572+
4573+ // CogView has a 2^21px pixel count limit, GLM-Image does not
4574+ if (!isGlmImage) {
4575+ while ((width * height) > Math.pow(2, 21)) {
4576+ if (width >= height) {
4577+ width -= multiple;
4578+ } else {
4579+ height -= multiple;
4580+ }
4581+ }
4582+ }
4583+
4584+ const result = await fetch('/api/sd/zai/generate', {
4585+ method: 'POST',
4586+ headers: getRequestHeaders(),
4587+ signal: signal,
4588+ body: JSON.stringify({
4589+ prompt: prompt,
4590+ model: extension_settings.sd.model,
4591+ quality: extension_settings.sd.openai_quality,
4592+ size: `${width}x${height}`,
4593+ }),
4594+ });
4595+
4596+ if (result.ok) {
4597+ const data = await result.json();
4598+ return { format: data.format, data: data.image };
4599+ }
4600+
4601+ const text = await result.text();
4602+ throw new Error(text);
4603+ }
42774604}
42784605
42794606/**
@@ -4443,6 +4770,58 @@ async function onComfyDeleteWorkflowClick() {
44434770 onComfyWorkflowChange();
44444771}
44454772
4773+async function onComfyRenameWorkflowClick() {
4774+ const oldName = extension_settings.sd.comfy_workflow;
4775+
4776+ if (!oldName) {
4777+ return;
4778+ }
4779+
4780+ let newName = await callGenericPopup(t`Enter new workflow name:`, POPUP_TYPE.INPUT, oldName);
4781+
4782+ if (!newName) {
4783+ return;
4784+ }
4785+
4786+ newName = String(newName).trim();
4787+
4788+ if (!newName.toLowerCase().endsWith('.json')) {
4789+ newName += '.json';
4790+ }
4791+
4792+ if (newName === oldName) {
4793+ return;
4794+ }
4795+
4796+ const existingWorkflow = Array
4797+ .from(document.querySelectorAll('#sd_comfy_workflow option'))
4798+ .find(opt => opt instanceof HTMLOptionElement && opt.value === newName);
4799+
4800+ if (existingWorkflow) {
4801+ toastr.warning(t`A workflow with that name already exists`);
4802+ return;
4803+ }
4804+
4805+ const response = await fetch('/api/sd/comfy/rename-workflow', {
4806+ method: 'POST',
4807+ headers: getRequestHeaders(),
4808+ body: JSON.stringify({
4809+ old_name: oldName,
4810+ new_name: newName,
4811+ }),
4812+ });
4813+
4814+ if (!response.ok) {
4815+ const text = await response.text();
4816+ toastr.error(t`Failed to rename workflow.\n\n${text}`);
4817+ return;
4818+ }
4819+
4820+ extension_settings.sd.comfy_workflow = newName;
4821+ saveSettingsDebounced();
4822+ await loadComfyWorkflows();
4823+}
4824+
44464825/**
44474826 * Sends a chat message with the generated image.
44484827 * @param {string} prompt Prompt used for the image generation
@@ -4575,6 +4954,8 @@ function isValidState() {
45754954 return true;
45764955 case sources.auto:
45774956 return !!extension_settings.sd.auto_url;
4957+ case sources.sdcpp:
4958+ return !!extension_settings.sd.sdcpp_url;
45784959 case sources.drawthings:
45794960 return !!extension_settings.sd.drawthings_url;
45804961 case sources.vlad:
@@ -4598,7 +4979,7 @@ function isValidState() {
45984979 case sources.togetherai:
45994980 return secret_state[SECRET_KEYS.TOGETHERAI];
46004981 case sources.pollinations:
46014982 return truesecret_state[SECRET_KEYS.POLLINATIONS];
46024983 case sources.stability:
46034984 return secret_state[SECRET_KEYS.STABILITY];
46044985 case sources.huggingface:
@@ -4626,7 +5007,8 @@ function isValidState() {
46265007 }
46275008}
46285009
4629-let buttonAbortController = null;
5010+/** @type {WeakMap<HTMLElement, AbortController>} */
5011+const buttonAbortControllers = new WeakMap();
46305012
46315013/**
46325014 * "Paintbrush" button handler to generate a new image for a message.
@@ -4644,16 +5026,30 @@ async function sdMessageButton($icon, { animate } = {}) {
46445026 $icon.toggleClass(classes.idle, !isBusy);
46455027 $icon.toggleClass(classes.busy, isBusy);
46465028 $media.toggleClass(classes.animation, isBusy);
5029+
5030+ // Update generation counter toast
5031+ const trackingFunction = isBusy ? startGenerationTracking : endGenerationTracking;
5032+ trackingFunction();
46475033 }
46485034
46495035 let $media = jQuery();
46505036
46515037 const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush', animation: 'fa-fade' };
46525038 const context = getContext();
5039+ const abortController = (() => {
5040+ const nativeElement = $icon.get(0);
5041+ if (buttonAbortControllers.has(nativeElement)) {
5042+ return buttonAbortControllers.get(nativeElement);
5043+ } else {
5044+ const controller = new AbortController();
5045+ buttonAbortControllers.set(nativeElement, controller);
5046+ return controller;
5047+ }
5048+ })();
46535049
46545050 if ($icon.hasClass(classes.busy)) {
46555051 buttonAbortController?abortController.abort('Aborted by user');
46565052 console.log('PreviousSD: imageImage isgeneration stillaborted beingby generated...user');
46575053 return;
46585054 }
46595055
@@ -4690,13 +5086,12 @@ async function sdMessageButton($icon, { animate } = {}) {
46905086 $media = messageElement.find(`.mes_media_container[data-index="${index}"]`).find('.mes_img, .mes_video');
46915087 }
46925088
4693- buttonAbortController = new AbortController();
46945089 const newMediaAttachment = await generateMediaSwipe(
46955090 selectedMedia,
46965091 message,
46975092 () => setBusyIcon(true),
46985093 () => setBusyIcon(false),
46995094 buttonAbortControllerabortController,
47005095 );
47015096
47025097 if (!newMediaAttachment) {
@@ -4869,6 +5264,17 @@ function applyCommandArguments(args) {
48695264 'denoise': 'denoising_strength',
48705265 '2ndpass': 'hr_second_pass_steps',
48715266 'faces': 'restore_faces',
5267+ 'processing': 'minimal_prompt_processing',
5268+ };
5269+ const enumHandlers = {
5270+ 'processing': (value) => {
5271+ if (/standard/gi.test(String(value))) {
5272+ return false;
5273+ }
5274+ if (/minimal/gi.test(String(value))) {
5275+ return true;
5276+ }
5277+ },
48725278 };
48735279
48745280 for (const [param, setting] of Object.entries(settingMap)) {
@@ -4877,6 +5283,14 @@ function applyCommandArguments(args) {
48775283 }
48785284 currentSettings[setting] = extension_settings.sd[setting];
48795285 const value = String(args[param]);
5286+ const enumHandler = enumHandlers[param];
5287+ if (typeof enumHandler === 'function') {
5288+ const enumValue = enumHandler(value);
5289+ if (enumValue !== undefined) {
5290+ overrideSettings[setting] = enumValue;
5291+ }
5292+ continue;
5293+ }
48805294 const type = typeof defaultSettings[setting];
48815295 switch (type) {
48825296 case 'boolean':
@@ -4999,6 +5413,17 @@ jQuery(async () => {
49995413 acceptsMultiple: false,
50005414 }),
50015415 SlashCommandNamedArgument.fromProps({
5416+ name: 'processing',
5417+ description: 'level of response prompt processing returned by the LLM',
5418+ typeList: [ARGUMENT_TYPE.STRING],
5419+ enumList: [
5420+ new SlashCommandEnumValue('standard', 'Standard prompt processing'),
5421+ new SlashCommandEnumValue('minimal', 'Minimal prompt processing'),
5422+ ],
5423+ isRequired: false,
5424+ acceptsMultiple: false,
5425+ }),
5426+ SlashCommandNamedArgument.fromProps({
50025427 name: 'seed',
50035428 description: 'random seed',
50045429 isRequired: false,
@@ -5243,6 +5668,8 @@ jQuery(async () => {
52435668 $('#sd_auto_validate').on('click', validateAutoUrl);
52445669 $('#sd_auto_url').on('input', onAutoUrlInput);
52455670 $('#sd_auto_auth').on('input', onAutoAuthInput);
5671+ $('#sd_sdcpp_validate').on('click', validateSdcppUrl);
5672+ $('#sd_sdcpp_url').on('input', onSdcppUrlInput);
52465673 $('#sd_drawthings_validate').on('click', validateDrawthingsUrl);
52475674 $('#sd_drawthings_url').on('input', onDrawthingsUrlInput);
52485675 $('#sd_drawthings_auth').on('input', onDrawthingsAuthInput);
@@ -5268,9 +5695,11 @@ jQuery(async () => {
52685695 $('#sd_comfy_workflow').on('change', onComfyWorkflowChange);
52695696 $('#sd_comfy_open_workflow_editor').on('click', onComfyOpenWorkflowEditorClick);
52705697 $('#sd_comfy_new_workflow').on('click', onComfyNewWorkflowClick);
5698+ $('#sd_comfy_rename_workflow').on('click', onComfyRenameWorkflowClick);
52715699 $('#sd_comfy_delete_workflow').on('click', onComfyDeleteWorkflowClick);
52725700 $('#sd_style').on('change', onStyleSelect);
52735701 $('#sd_save_style').on('click', onSaveStyleClick);
5702+ $('#sd_rename_style').on('click', onRenameStyleClick);
52745703 $('#sd_delete_style').on('click', onDeleteStyleClick);
52755704 $('#sd_character_prompt_block').hide();
52765705 $('#sd_interactive_mode').on('input', onInteractiveModeInput);
@@ -5279,6 +5708,7 @@ jQuery(async () => {
52795708 $('#sd_openai_duration').on('input', onOpenAiDurationSelect);
52805709 $('#sd_multimodal_captioning').on('input', onMultimodalCaptioningInput);
52815710 $('#sd_snap').on('input', onSnapInput);
5711+ $('#sd_minimal_prompt_processing').on('input', onMinimalPromptProcessing);
52825712 $('#sd_clip_skip').on('input', onClipSkipInput);
52835713 $('#sd_seed').on('input', onSeedInput);
52845714 $('#sd_character_prompt_share').on('input', onCharacterPromptShareInput);
@@ -5309,6 +5739,10 @@ jQuery(async () => {
53095739 extension_settings.sd.electronhub_quality = String($(this).val());
53105740 saveSettingsDebounced();
53115741 });
5742+ $('#sd_openai_quality_gpt').on('input', function () {
5743+ extension_settings.sd.openai_quality_gpt = String($(this).val());
5744+ saveSettingsDebounced();
5745+ });
53125746
53135747 if (!CSS.supports('field-sizing', 'content')) {
53145748 $('.sd_settings .inline-drawer-toggle').on('click', function () {
@@ -5337,15 +5771,19 @@ jQuery(async () => {
53375771
53385772 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
53395773 eventSource.on(event, async (/** @type {string} */ key) => {
53405774 switchconst (key)keySourceMap = {
53415775 case[sources.bfl]: SECRET_KEYS.BFL:,
53425776 case[sources.falai]: SECRET_KEYS.FALAI:,
53435777 case[sources.stability]: SECRET_KEYS.STABILITY:,
53445778 case[sources.aimlapi]: SECRET_KEYS.AIMLAPI:,
53455779 case[sources.comfy]: SECRET_KEYS.COMFY_RUNPOD:,
5346- await loadSettingOptions();
5780+ [sources.pollinations]: SECRET_KEYS.POLLINATIONS,
5347- break;
5781+ };
5782+ const shouldReloadOptions = Object.entries(keySourceMap).some(([k, v]) => k === extension_settings.sd.source && v === key);
5783+ if (!shouldReloadOptions) {
5784+ return;
53485785 }
5786+ await loadSettingOptions();
53495787 });
53505788 });
53515789
public/scripts/extensions/stable-diffusion/settings.html+47 -9
@@ -35,6 +35,10 @@
3535 <input id="sd_snap" type="checkbox" />
3636 <span data-i18n="sd_snap_txt">Snap auto-adjusted resolutions</span>
3737 </label>
38+ <label for="sd_minimal_prompt_processing" class="checkbox_label" data-i18n="[title]sd_minimal_prompt_processing" title="Reduce post-processing on a prompt generated by the LLM to preserve JSON and other structured output.">
39+ <input id="sd_minimal_prompt_processing" type="checkbox" />
40+ <span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span>
41+ </label>
3842 <label for="sd_source" data-i18n="Source">Source</label>
3943 <select id="sd_source">
4044 <option value="aimlapi">AI/ML API</option>
@@ -55,10 +59,11 @@
5559 <option value="vlad">SD.Next (vladmandic)</option>
5660 <option value="stability">Stability AI</option>
5761 <option value="auto">Stable Diffusion Web UI (AUTOMATIC1111)</option>
62+ <option value="sdcpp">stable-diffusion.cpp server</option>
5863 <option value="horde">Stable Horde</option>
5964 <option value="togetherai">TogetherAI</option>
6065 <option value="xai">xAI (Grok)</option>
6166 <option value="zai">Z.AI (CogView)</option>
6267 </select>
6368 <div data-sd-source="auto">
6469 <label for="sd_auto_url">SD Web UI URL</label>
@@ -76,6 +81,19 @@
7681 <!-- (Original Text)<b>Important:</b> run SD Web UI with the <tt>--api</tt> flag! The server must be accessible from the SillyTavern host machine. -->
7782 <i><b data-i18n="Important:">Important:</b></i><i data-i18n="sd_auto_auth_warning_1"> run SD Web UI with the </i><i><tt>--api</tt></i><i data-i18n="sd_auto_auth_warning_2"> flag! The server must be accessible from the SillyTavern host machine.</i>
7883 </div>
84+ <div data-sd-source="sdcpp">
85+ <label for="sd_sdcpp_url">stable-diffusion.cpp URL</label>
86+ <div class="flex-container flexnowrap">
87+ <input id="sd_sdcpp_url" type="text" class="text_pole" placeholder="Example: {{sdcpp_url}}" value="{{sdcpp_url}}" />
88+ <div id="sd_sdcpp_validate" class="menu_button menu_button_icon">
89+ <i class="fa-solid fa-check"></i>
90+ <span data-i18n="Connect">
91+ Connect
92+ </span>
93+ </div>
94+ </div>
95+ <i data-i18n="The server must be accessible from the SillyTavern host machine.">The server must be accessible from the SillyTavern host machine.</i>
96+ </div>
7997 <div data-sd-source="drawthings">
8098 <label for="sd_drawthings_url">DrawThings API URL</label>
8199 <div class="flex-container flexnowrap">
@@ -178,7 +196,16 @@
178196 <option value="natural">Natural</option>
179197 </select>
180198 </div>
181199 <div data-sd-model="dall-e-3,cogview-4gpt-250304image" class="flex1">
200+ <label for="sd_openai_quality_gpt" data-i18n="Image Quality">Image Quality</label>
201+ <select id="sd_openai_quality_gpt">
202+ <option value="auto" data-i18n="Auto">Auto</option>
203+ <option value="low" data-i18n="Low">Low</option>
204+ <option value="medium" data-i18n="Medium">Medium</option>
205+ <option value="high" data-i18n="High">High</option>
206+ </select>
207+ </div>
208+ <div data-sd-model="dall-e-3,cogview-4,glm-image,cogvideox" class="flex1">
182209 <label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label>
183210 <select id="sd_openai_quality">
184211 <option value="standard" data-i18n="Standard">Standard</option>
@@ -248,15 +275,23 @@
248275 <div id="sd_comfy_new_workflow" class="menu_button menu_button_icon" data-i18n="[title]Create new workflow" title="Create new workflow">
249276 <i class="fa-solid fa-plus"></i>
250277 </div>
278+ <div id="sd_comfy_rename_workflow" class="menu_button menu_button_icon" data-i18n="[title]Rename workflow" title="Rename workflow">
279+ <i class="fa-solid fa-pencil"></i>
280+ </div>
251281 <div id="sd_comfy_delete_workflow" class="menu_button menu_button_icon" data-i18n="[title]Delete workflow" title="Delete workflow">
252282 <i class="fa-solid fa-trash-can"></i>
253283 </div>
254284 </div>
255285 </div>
256286 <div data-sd-source="pollinations">
257- <p>
287+ <a href="https://enter.pollinations.ai">Pollinations.ai</a>
258- <a href="https://pollinations.ai">Pollinations.ai</a>
288+ <div class="flex-container flexnowrap alignItemsBaseline marginBot5">
259- </p>
289+ <strong class="flex1" data-i18n="API Key">API Key</strong>
290+ <div id="sd_pollinations_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_pollinations">
291+ <i class="fa-fw fa-solid fa-key"></i>
292+ <span data-i18n="Click to set">Click to set</span>
293+ </div>
294+ </div>
260295 <div class="flex-container">
261296 <label class="flex1 checkbox_label" for="sd_pollinations_enhance" data-i18n="[title]Enables prompt enhancing (passes prompts through an LLM to add detail)." title="Enables prompt enhancing (passes prompts through an LLM to add detail).">
262297 <input id="sd_pollinations_enhance" type="checkbox" />
@@ -381,12 +416,12 @@
381416 </div>
382417
383418 <div class="flex-container">
384419 <div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy,sdcpp">
385420 <label for="sd_sampler" data-i18n="Sampling method">Sampling method</label>
386421 <select id="sd_sampler"></select>
387422 </div>
388423
389424 <div class="flex1" data-sd-source="comfy,auto,novel,sdcpp">
390425 <label for="sd_scheduler" data-i18n="Scheduler">Scheduler</label>
391426 <select id="sd_scheduler"></select>
392427 </div>
@@ -469,7 +504,7 @@
469504 <input class="neo-range-input" type="number" id="sd_hr_second_pass_steps_value" data-for="sd_hr_second_pass_steps" max="{{hr_second_pass_steps_max}}" step="{{hr_second_pass_steps_step}}" value="{{hr_second_pass_steps}}" >
470505 </div>
471506
472507 <div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad,comfy,horde,drawthings,extras,sdcpp">
473508 <small>
474509 <span data-i18n="CLIP Skip">CLIP Skip</span>
475510 </small>
@@ -523,7 +558,7 @@
523558 </label>
524559 </div>
525560
526561 <div data-sd-source="novel,togetherai,pollinations,comfy,drawthings,vlad,auto,horde,extras,stability,bfl,sdcpp" class="marginTop5">
527562 <label for="sd_seed">
528563 <span data-i18n="Seed">Seed</span>
529564 <small data-i18n="(-1 for random)">(-1 for random)</small>
@@ -540,6 +575,9 @@
540575 <div id="sd_save_style" data-i18n="[title]Save style" title="Save style" class="menu_button">
541576 <i class="fa-solid fa-save"></i>
542577 </div>
578+ <div id="sd_rename_style" data-i18n="[title]Rename style" title="Rename style" class="menu_button">
579+ <i class="fa-solid fa-pencil"></i>
580+ </div>
543581 <div id="sd_delete_style" data-i18n="[title]Delete style" title="Delete style" class="menu_button">
544582 <i class="fa-solid fa-trash-can"></i>
545583 </div>
public/scripts/extensions/tts/coqui.js+7 -9
@@ -207,13 +207,13 @@ class CoquiTtsProvider {
207207 this.settings.customVoices = {};
208208 for (let voiceName in this.settings.voiceMapDict) {
209209 const voiceId = this.settings.voiceMapDict[voiceName];
210210 this.settings.customVoices[voiceName] = voiceId['.model_id'];
211211
212212 if (voiceId['.model_language'] != null)
213213 this.settings.customVoices[voiceName] += '[' + voiceId['.model_language'] + ']';
214214
215215 if (voiceId['.model_speaker'] != null)
216216 this.settings.customVoices[voiceName] += '[' + voiceId['.model_speaker'] + ']';
217217 }
218218
219219 // Update UI select list with voices
@@ -493,8 +493,8 @@ class CoquiTtsProvider {
493493 .append('<option value="none">Select language</option>')
494494 .val('none');
495495
496496 for (let i = 0; i < model_settings['.languages'].length; i++) {
497497 const language_label = JSON.stringify(model_settings['.languages'][i]).replaceAll('"', '');
498498 $('#coqui_api_model_settings_language').append(new Option(language_label, i));
499499 }
500500 }
@@ -512,8 +512,8 @@ class CoquiTtsProvider {
512512 .append('<option value="none">Select speaker</option>')
513513 .val('none');
514514
515- for (let i = 0; i < model_settings['speakers'].length; i++) {
516- const speaker_label = JSON.stringify(model_settings['speakers'][i]).replaceAll('"', '');
public/scripts/extensions/tts/cosyvoice.js+0 -0
public/scripts/extensions/tts/elevenlabs.js+0 -0
public/scripts/extensions/tts/gpt-sovits-adapter.js+0 -0
public/scripts/extensions/tts/index.js+0 -0
public/scripts/extensions/tts/settings.html+0 -0
public/scripts/extensions/tts/volcengine.js+0 -0
public/scripts/extensions/vectors/index.js+0 -0
public/scripts/extensions/vectors/settings.html+0 -0
public/scripts/group-chats.js+0 -0
public/scripts/horde.js+0 -0
public/scripts/i18n.js+0 -0
public/scripts/instruct-mode.js+0 -0
public/scripts/itemized-prompts.js+0 -0
public/scripts/macros.js+0 -0
public/scripts/macros/definitions/chat-macros.js+0 -0
public/scripts/macros/definitions/core-macros.js+0 -0
public/scripts/macros/definitions/state-macros.js+0 -0
public/scripts/macros/definitions/variable-macros.js+0 -0
public/scripts/macros/MacroBrowser.js → public/scripts/macros/engine/MacroBrowser.js+0 -0
public/scripts/macros/engine/MacroCstWalker.js+0 -0
public/scripts/macros/engine/MacroDiagnostics.js+0 -0
public/scripts/macros/engine/MacroEngine.js+0 -0
public/scripts/macros/engine/MacroEnv.types.js+0 -0
public/scripts/macros/engine/MacroEnvBuilder.js+0 -0
public/scripts/macros/engine/MacroFlags.js+0 -0
public/scripts/macros/engine/MacroLexer.js+0 -0
public/scripts/macros/engine/MacroParser.js+0 -0
public/scripts/macros/engine/MacroRegistry.js+0 -0
public/scripts/macros/macro-system.js+0 -0
public/scripts/openai.js+0 -0
public/scripts/personas.js+0 -0
public/scripts/power-user.js+0 -0
public/scripts/preset-manager.js+0 -0
public/scripts/reasoning.js+0 -0
public/scripts/samplerSelect.js+0 -0
public/scripts/secrets.js+0 -0
public/scripts/slash-commands.js+0 -0
public/scripts/slash-commands/SlashCommandClosure.js+0 -0
public/scripts/slash-commands/SlashCommandParser.js+0 -0
public/scripts/st-context.js+0 -0
public/scripts/system-messages.js+0 -0
public/scripts/tags.js+0 -0
public/scripts/templates/welcomePanel.html+0 -0
public/scripts/textgen-models.js+0 -0
public/scripts/textgen-settings.js+0 -0
public/scripts/tokenizers.js+0 -0
public/scripts/tool-calling.js+0 -0
public/scripts/util/AccountStorage.js+0 -0
public/scripts/utils.js+0 -0
public/scripts/variables.js+0 -0
public/scripts/welcome-screen.js+0 -0
public/scripts/world-info.js+0 -0
public/style.css+0 -0
src/command-line.js+0 -0
src/config-init.js+0 -0
src/constants.js+0 -0
src/endpoints/assets.js+0 -0
src/endpoints/backends/chat-completions.js+0 -0
src/endpoints/backends/kobold.js+0 -0
src/endpoints/backends/text-completions.js+0 -0
src/endpoints/backgrounds.js+0 -0
src/endpoints/characters.js+0 -0
src/endpoints/chats.js+0 -0
src/endpoints/content-manager.js+0 -0
src/endpoints/groups.js+0 -0
src/endpoints/image-metadata.js+0 -0
src/endpoints/openai.js+0 -0
src/endpoints/openrouter.js+0 -0
src/endpoints/search.js+0 -0
src/endpoints/secrets.js+0 -0
src/endpoints/speech.js+0 -0
src/endpoints/stable-diffusion.js+0 -0
src/endpoints/thumbnails.js+0 -0
src/endpoints/tokenizers.js+0 -0
src/endpoints/translate.js+0 -0
src/endpoints/users-admin.js+0 -0
src/endpoints/vectors.js+0 -0
src/endpoints/volcengine.js+0 -0
src/healthcheck.js+0 -0
src/middleware/basicAuth.js+0 -0
src/prompt-converters.js+0 -0
src/server-main.js+0 -0
src/server-startup.js+0 -0
src/users.js+0 -0
src/util.js+0 -0
src/vectors/openai-vectors.js+0 -0
tests/.eslintrc.cjs+0 -0
tests/frontend/MacroEngine.e2e.js+0 -0
tests/frontend/MacroEnvBuilder.e2e.js+0 -0
tests/frontend/MacroLexer.e2e.js+0 -0
tests/frontend/MacroParser.e2e.js+0 -0
tests/frontend/MacroRegistry.e2e.js+0 -0
tests/frontend/MacroSlashCommands.e2e.js+0 -0
tests/frontend/MacroStoryString.e2e.js+0 -0
tests/package-lock.json+0 -0
Diff truncated