Merge branch 'staging' of github.com-qvink:SillyTavern/SillyTavern into get_chat_completion_presets_from_preset_manager

10a72b8c8043a16d2bfd014ed6db5a5a2aaa2660

qvink <qvink@users.noreply.github.com>

68 files changed, +2027 -1154Showing whitespace changes
Dockerfile+1 -1
@@ -4,7 +4,7 @@ FROM node:lts-alpine3.19
4ARG APP_HOME=/home/node/app4ARG APP_HOME=/home/node/app
55
6# Install system dependencies6# Install system dependencies
7RUN apk add gcompat tini git7RUN apk add --no-cache gcompat tini git
88
9# Create app directory9# Create app directory
10WORKDIR ${APP_HOME}10WORKDIR ${APP_HOME}
default/!DO-NOT-EDIT-THESE-FILES.txt+13 -0
@@ -0,0 +1,13 @@
1These are master copies of the default content files and are managed by SillyTavern.
2
3Editing any of these files would not only have no effect, but will also cause merge conflicts during update pulls.
4
5You should edit their respective copies instead, for example:
6
71. /default/config.yaml => /config.yaml
82. /default/public/css/user.css => /public/css/user.css
9etc.
10
11Any questions? You're always welcome at our official documentation website:
12
13https://docs.sillytavern.app/
default/config.yaml+12 -2
@@ -71,8 +71,6 @@ autheliaAuth: false
71# the username and passwords for basic auth are the same as those71# the username and passwords for basic auth are the same as those
72# for the individual accounts72# for the individual accounts
73perUserBasicAuth: false73perUserBasicAuth: false
74# Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3)
75minLogLevel: 0
7674
77# User session timeout *in seconds* (defaults to 24 hours).75# User session timeout *in seconds* (defaults to 24 hours).
78## Set to a positive number to expire session after a certain time of inactivity76## Set to a positive number to expire session after a certain time of inactivity
@@ -85,6 +83,18 @@ cookieSecret: ''
85disableCsrfProtection: false83disableCsrfProtection: false
86# Disable startup security checks - NOT RECOMMENDED84# Disable startup security checks - NOT RECOMMENDED
87securityOverride: false85securityOverride: false
86# -- LOGGING CONFIGURATION --
87logging:
88 # Enable access logging to access.log file
89 # Records new connections with timestamp, IP address and user agent
90 enableAccessLog: true
91 # Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3)
92 minLogLevel: 0
93# -- RATE LIMITING CONFIGURATION --
94rateLimiting:
95 # Use X-Real-IP header instead of socket IP for rate limiting
96 # Only enable this if you are using a properly configured reverse proxy (like Nginx/traefik/Caddy)
97 preferRealIpHeader: false
88# -- ADVANCED CONFIGURATION --98# -- ADVANCED CONFIGURATION --
89# Open the browser automatically99# Open the browser automatically
90autorun: true100autorun: true
docker/build-lib.js+1 -1
@@ -1,4 +1,4 @@
1import getWebpackServeMiddleware from '../src/middleware/webpack-serve.js';1import getWebpackServeMiddleware from '../src/middleware/webpack-serve.js';
22
3const middleware = getWebpackServeMiddleware();3const middleware = getWebpackServeMiddleware();
4await middleware.runWebpackCompiler();4await middleware.runWebpackCompiler({ forceDist: true });
package-lock.json+45 -22
@@ -28,7 +28,7 @@
28 "cors": "^2.8.5",28 "cors": "^2.8.5",
29 "csrf-sync": "^4.0.3",29 "csrf-sync": "^4.0.3",
30 "diff-match-patch": "^1.0.5",30 "diff-match-patch": "^1.0.5",
31 "dompurify": "^3.1.7",31 "dompurify": "^3.2.4",
32 "droll": "^0.2.1",32 "droll": "^0.2.1",
33 "express": "^4.21.0",33 "express": "^4.21.0",
34 "form-data": "^4.0.0",34 "form-data": "^4.0.0",
@@ -43,6 +43,7 @@
43 "ip-matching": "^2.1.2",43 "ip-matching": "^2.1.2",
44 "ip-regex": "^5.0.0",44 "ip-regex": "^5.0.0",
45 "ipaddr.js": "^2.0.1",45 "ipaddr.js": "^2.0.1",
46 "is-docker": "^3.0.0",
46 "jimp": "^0.22.10",47 "jimp": "^0.22.10",
47 "localforage": "^1.10.0",48 "localforage": "^1.10.0",
48 "lodash": "^4.17.21",49 "lodash": "^4.17.21",
@@ -87,7 +88,6 @@
87 "@types/cookie-session": "^2.0.49",88 "@types/cookie-session": "^2.0.49",
88 "@types/cors": "^2.8.17",89 "@types/cors": "^2.8.17",
89 "@types/deno": "^2.0.0",90 "@types/deno": "^2.0.0",
90 "@types/dompurify": "^3.0.5",
91 "@types/express": "^4.17.21",91 "@types/express": "^4.17.21",
92 "@types/jquery": "^3.5.29",92 "@types/jquery": "^3.5.29",
93 "@types/jquery-cropper": "^1.0.4",93 "@types/jquery-cropper": "^1.0.4",
@@ -1181,16 +1181,6 @@
1181 "dev": true,1181 "dev": true,
1182 "license": "MIT"1182 "license": "MIT"
1183 },1183 },
1184 "node_modules/@types/dompurify": {
1185 "version": "3.0.5",
1186 "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
1187 "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
1188 "dev": true,
1189 "license": "MIT",
1190 "dependencies": {
1191 "@types/trusted-types": "*"
1192 }
1193 },
1194 "node_modules/@types/estree": {1184 "node_modules/@types/estree": {
1195 "version": "1.0.6",1185 "version": "1.0.6",
1196 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",1186 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -1478,8 +1468,8 @@
1478 "version": "2.0.7",1468 "version": "2.0.7",
1479 "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",1469 "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
1480 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",1470 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
1481 "dev": true,1471 "license": "MIT",
1482 "license": "MIT"1472 "optional": true
1483 },1473 },
1484 "node_modules/@types/write-file-atomic": {1474 "node_modules/@types/write-file-atomic": {
1485 "version": "4.0.3",1475 "version": "4.0.3",
@@ -3236,10 +3226,13 @@
3236 }3226 }
3237 },3227 },
3238 "node_modules/dompurify": {3228 "node_modules/dompurify": {
3239 "version": "3.1.7",3229 "version": "3.2.4",
3240 "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",3230 "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
3241 "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",3231 "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
3242 "license": "(MPL-2.0 OR Apache-2.0)"3232 "license": "(MPL-2.0 OR Apache-2.0)",
3233 "optionalDependencies": {
3234 "@types/trusted-types": "^2.0.7"
3235 }
3243 },3236 },
3244 "node_modules/domutils": {3237 "node_modules/domutils": {
3245 "version": "3.1.0",3238 "version": "3.1.0",
@@ -4657,15 +4650,15 @@
4657 "license": "MIT"4650 "license": "MIT"
4658 },4651 },
4659 "node_modules/is-docker": {4652 "node_modules/is-docker": {
4660 "version": "2.2.1",4653 "version": "3.0.0",
4661 "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",4654 "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
4662 "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",4655 "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
4663 "license": "MIT",4656 "license": "MIT",
4664 "bin": {4657 "bin": {
4665 "is-docker": "cli.js"4658 "is-docker": "cli.js"
4666 },4659 },
4667 "engines": {4660 "engines": {
4668 "node": ">=8"4661 "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
4669 },4662 },
4670 "funding": {4663 "funding": {
4671 "url": "https://github.com/sponsors/sindresorhus"4664 "url": "https://github.com/sponsors/sindresorhus"
@@ -4742,6 +4735,21 @@
4742 "node": ">=8"4735 "node": ">=8"
4743 }4736 }
4744 },4737 },
4738 "node_modules/is-wsl/node_modules/is-docker": {
4739 "version": "2.2.1",
4740 "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
4741 "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
4742 "license": "MIT",
4743 "bin": {
4744 "is-docker": "cli.js"
4745 },
4746 "engines": {
4747 "node": ">=8"
4748 },
4749 "funding": {
4750 "url": "https://github.com/sponsors/sindresorhus"
4751 }
4752 },
4745 "node_modules/isarray": {4753 "node_modules/isarray": {
4746 "version": "1.0.0",4754 "version": "1.0.0",
4747 "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",4755 "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -5526,6 +5534,21 @@
5526 "url": "https://github.com/sponsors/sindresorhus"5534 "url": "https://github.com/sponsors/sindresorhus"
5527 }5535 }
5528 },5536 },
5537 "node_modules/open/node_modules/is-docker": {
5538 "version": "2.2.1",
5539 "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
5540 "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
5541 "license": "MIT",
5542 "bin": {
5543 "is-docker": "cli.js"
5544 },
5545 "engines": {
5546 "node": ">=8"
5547 },
5548 "funding": {
5549 "url": "https://github.com/sponsors/sindresorhus"
5550 }
5551 },
5529 "node_modules/openai": {5552 "node_modules/openai": {
5530 "version": "4.17.4",5553 "version": "4.17.4",
5531 "resolved": "https://registry.npmjs.org/openai/-/openai-4.17.4.tgz",5554 "resolved": "https://registry.npmjs.org/openai/-/openai-4.17.4.tgz",
package.json+2 -2
@@ -18,7 +18,7 @@
18 "cors": "^2.8.5",18 "cors": "^2.8.5",
19 "csrf-sync": "^4.0.3",19 "csrf-sync": "^4.0.3",
20 "diff-match-patch": "^1.0.5",20 "diff-match-patch": "^1.0.5",
21 "dompurify": "^3.1.7",21 "dompurify": "^3.2.4",
22 "droll": "^0.2.1",22 "droll": "^0.2.1",
23 "express": "^4.21.0",23 "express": "^4.21.0",
24 "form-data": "^4.0.0",24 "form-data": "^4.0.0",
@@ -33,6 +33,7 @@
33 "ip-matching": "^2.1.2",33 "ip-matching": "^2.1.2",
34 "ip-regex": "^5.0.0",34 "ip-regex": "^5.0.0",
35 "ipaddr.js": "^2.0.1",35 "ipaddr.js": "^2.0.1",
36 "is-docker": "^3.0.0",
36 "jimp": "^0.22.10",37 "jimp": "^0.22.10",
37 "localforage": "^1.10.0",38 "localforage": "^1.10.0",
38 "lodash": "^4.17.21",39 "lodash": "^4.17.21",
@@ -116,7 +117,6 @@
116 "@types/cookie-session": "^2.0.49",117 "@types/cookie-session": "^2.0.49",
117 "@types/cors": "^2.8.17",118 "@types/cors": "^2.8.17",
118 "@types/deno": "^2.0.0",119 "@types/deno": "^2.0.0",
119 "@types/dompurify": "^3.0.5",
120 "@types/express": "^4.17.21",120 "@types/express": "^4.17.21",
121 "@types/jquery": "^3.5.29",121 "@types/jquery": "^3.5.29",
122 "@types/jquery-cropper": "^1.0.4",122 "@types/jquery-cropper": "^1.0.4",
plugins.js+2 -2
@@ -8,7 +8,7 @@ import path from 'node:path';
8import process from 'node:process';8import process from 'node:process';
9import { fileURLToPath } from 'node:url';9import { fileURLToPath } from 'node:url';
1010
11import { default as git } from 'simple-git';11import { default as git, CheckRepoActions } from 'simple-git';
12import { color } from './src/util.js';12import { color } from './src/util.js';
1313
14const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));14const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -49,7 +49,7 @@ async function updatePlugins() {
49 const pluginPath = path.join(pluginsPath, directory);49 const pluginPath = path.join(pluginsPath, directory);
50 const pluginRepo = git(pluginPath);50 const pluginRepo = git(pluginPath);
5151
52 const isRepo = await pluginRepo.checkIsRepo();52 const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
53 if (!isRepo) {53 if (!isRepo) {
54 console.log(`Directory ${color.yellow(directory)} is not a Git repository`);54 console.log(`Directory ${color.yellow(directory)} is not a Git repository`);
55 continue;55 continue;
post-install.js+5 -0
@@ -104,6 +104,11 @@ const keyMigrationMap = [
104 newKey: 'extensions.models.textToSpeech',104 newKey: 'extensions.models.textToSpeech',
105 migrate: (value) => value,105 migrate: (value) => value,
106 },106 },
107 {
108 oldKey: 'minLogLevel',
109 newKey: 'logging.minLogLevel',
110 migrate: (value) => value,
111 },
107];112];
108113
109/**114/**
public/global.d.ts+8 -0
@@ -40,4 +40,12 @@ declare global {
40 searchInputCssClass?: string;40 searchInputCssClass?: string;
41 }41 }
42 }42 }
43
44 /**
45 * Translates a text to a target language using a translation provider.
46 * @param text Text to translate
47 * @param lang Target language
48 * @param provider Translation provider
49 */
50 async function translate(text: string, lang: string, provider: string = null): Promise<string>;
43}51}
public/index.html+33 -17
@@ -2000,7 +2000,7 @@
2000 </span>2000 </span>
2001 </div>2001 </div>
2002 </div>2002 </div>
2003 <div class="range-block" data-source="deepseek,openrouter">2003 <div class="range-block" data-source="deepseek,openrouter,custom">
2004 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">2004 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
2005 <input id="openai_show_thoughts" type="checkbox" />2005 <input id="openai_show_thoughts" type="checkbox" />
2006 <span>2006 <span>
@@ -2015,7 +2015,7 @@
2015 </div>2015 </div>
2016 </div>2016 </div>
2017 <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom">2017 <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom">
2018 <div class="flex-container oneline-dropdown" title="Constrains effort on reasoning for reasoning models.&#10;Currently supported values are low, medium, and high.&#10;Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response.">2018 <div class="flex-container oneline-dropdown" title="Constrains effort on reasoning for reasoning models.&#10;Currently supported values are low, medium, and high.&#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.">
2019 <label for="openai_reasoning_effort" data-i18n="Reasoning Effort">2019 <label for="openai_reasoning_effort" data-i18n="Reasoning Effort">
2020 Reasoning Effort2020 Reasoning Effort
2021 </label>2021 </label>
@@ -3186,21 +3186,33 @@
3186 </div>3186 </div>
3187 <h4 data-i18n="Groq Model">Groq Model</h4>3187 <h4 data-i18n="Groq Model">Groq Model</h4>
3188 <select id="model_groq_select">3188 <select id="model_groq_select">
3189 <optgroup label="Production Models">3189 <optgroup label="Alibaba Cloud">
3190 <option value="gemma2-9b-it">gemma2-9b-it</option>3190 <option value="qwen-2.5-32b">qwen-2.5-32b</option>
3191 <option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option>3191 <option value="qwen-2.5-coder-32b">qwen-2.5-coder-32b</option>
3192 <option value="llama-3.1-8b-instant">llama-3.1-8b-instant</option>
3193 <option value="llama3-70b-8192">llama3-70b-8192</option>
3194 <option value="llama3-8b-8192">llama3-8b-8192</option>
3195 <option value="mixtral-8x7b-32768">mixtral-8x7b-32768</option>
3196 </optgroup>3192 </optgroup>
3197 <optgroup label="Preview Models">3193 <optgroup label="DeepSeek / Alibaba Cloud">
3194 <option value="deepseek-r1-distill-qwen-32b">deepseek-r1-distill-qwen-32b</option>
3195 </optgroup>
3196 <optgroup label="DeepSeek / Meta">
3198 <option value="deepseek-r1-distill-llama-70b">deepseek-r1-distill-llama-70b</option>3197 <option value="deepseek-r1-distill-llama-70b">deepseek-r1-distill-llama-70b</option>
3199 <option value="llama-3.3-70b-specdec">llama-3.3-70b-specdec</option>3198 </optgroup>
3199 <optgroup label="Google">
3200 <option value="gemma2-9b-it">gemma2-9b-it</option>
3201 </optgroup>
3202 <optgroup label="Meta">
3203 <option value="llama-3.1-8b-instant">llama-3.1-8b-instant </option>
3204 <option value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview </option>
3200 <option value="llama-3.2-1b-preview">llama-3.2-1b-preview </option>3205 <option value="llama-3.2-1b-preview">llama-3.2-1b-preview </option>
3201 <option value="llama-3.2-3b-preview">llama-3.2-3b-preview </option>3206 <option value="llama-3.2-3b-preview">llama-3.2-3b-preview </option>
3202 <option value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview</option>
3203 <option value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview </option>3207 <option value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview </option>
3208 <option value="llama-3.3-70b-specdec">llama-3.3-70b-specdec </option>
3209 <option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile </option>
3210 <option value="llama-guard-3-8b">llama-guard-3-8b </option>
3211 <option value="llama3-70b-8192">llama3-70b-8192 </option>
3212 <option value="llama3-8b-8192">llama3-8b-8192 </option>
3213 </optgroup>
3214 <optgroup label="Mistral AI">
3215 <option value="mixtral-8x7b-32768">mixtral-8x7b-32768</option>
3204 </optgroup>3216 </optgroup>
3205 </select>3217 </select>
3206 </div>3218 </div>
@@ -3253,6 +3265,10 @@
3253 <option value="sonar">sonar</option>3265 <option value="sonar">sonar</option>
3254 <option value="sonar-pro">sonar-pro</option>3266 <option value="sonar-pro">sonar-pro</option>
3255 <option value="sonar-reasoning">sonar-reasoning</option>3267 <option value="sonar-reasoning">sonar-reasoning</option>
3268 <option value="sonar-reasoning-pro">sonar-reasoning-pro</option>
3269 </optgroup>
3270 <optgroup label="Offline Models">
3271 <option value="r1-1776">r1-1776</option>
3256 </optgroup>3272 </optgroup>
3257 <optgroup label="Deprecated Models">3273 <optgroup label="Deprecated Models">
3258 <!-- These are scheduled for deprecation after 2/22/2025 -->3274 <!-- These are scheduled for deprecation after 2/22/2025 -->
@@ -4001,7 +4017,7 @@
4001 <div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" title="Cap the number of entry activation recursions" data-i18n="[title]Cap the number of entry activation recursions">4017 <div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" title="Cap the number of entry activation recursions" data-i18n="[title]Cap the number of entry activation recursions">
4002 <small>4018 <small>
4003 <span data-i18n="Max Recursion Steps">Max Recursion Steps</span>4019 <span data-i18n="Max Recursion Steps">Max Recursion Steps</span>
4004 <div class="fa-solid fa-triangle-exclamation opacity50p" data-i18n="[title]0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc\n(disabled when min activations are used)" title="0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc&#10;(disabled when min activations are used)"></div>4020 <div class="fa-solid fa-triangle-exclamation opacity50p" data-i18n="[title]0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc" title="0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc&#10;(disabled when min activations are used)"></div>
4005 </small>4021 </small>
4006 <input class="neo-range-slider" type="range" id="world_info_max_recursion_steps" name="world_info_max_recursion_steps" min="0" max="10" step="1">4022 <input class="neo-range-slider" type="range" id="world_info_max_recursion_steps" name="world_info_max_recursion_steps" min="0" max="10" step="1">
4007 <input class="neo-range-input" type="number" min="0" max="10" step="1" data-for="world_info_max_recursion_steps" id="world_info_max_recursion_steps_counter">4023 <input class="neo-range-input" type="number" min="0" max="10" step="1" data-for="world_info_max_recursion_steps" id="world_info_max_recursion_steps_counter">
@@ -4413,7 +4429,7 @@
4413 <input id="prefer_character_jailbreak" type="checkbox" />4429 <input id="prefer_character_jailbreak" type="checkbox" />
4414 <small data-i18n="Prefer Character Card Instructions">Prefer Char. Instructions</small>4430 <small data-i18n="Prefer Character Card Instructions">Prefer Char. Instructions</small>
4415 </label>4431 </label>
4416 <label class="checkbox_label" for="never_resize_avatars" title="Avoid cropping and resizing imported character images. When off, crop/resize to 512x768." data-i18n="[title]Avoid cropping and resizing imported character images. When off, crop/resize to 512x768">4432 <label class="checkbox_label" for="never_resize_avatars" title="Avoid cropping and resizing imported character images. When off, crop/resize to 512x768.&#10;This will disable the upload cropping popup for avatars." data-i18n="[title]never_resize_avatars_tooltip">
4417 <input id="never_resize_avatars" type="checkbox" />4433 <input id="never_resize_avatars" type="checkbox" />
4418 <small data-i18n="Never resize avatars">Never resize avatars</small>4434 <small data-i18n="Never resize avatars">Never resize avatars</small>
4419 </label>4435 </label>
@@ -4666,7 +4682,7 @@
4666 <small data-i18n="Enabled">Enabled</small>4682 <small data-i18n="Enabled">Enabled</small>
4667 </label>4683 </label>
4668 <small data-i18n="Minimum generated message length">Minimum generated message length</small>4684 <small data-i18n="Minimum generated message length">Minimum generated message length</small>
4669 <input id="auto_swipe_minimum_length" name="auto_swipe_minimum_length" type="number" min="0" step="1" value="0" class="text_pole" title="If the generated message is shorter than this, trigger an auto-swipe." data-i18n="[title]If the generated message is shorter than this, trigger an auto-swipe">4685 <input id="auto_swipe_minimum_length" name="auto_swipe_minimum_length" type="number" min="0" step="1" value="0" class="text_pole" title="If the generated message is shorter than these many characters, trigger an auto-swipe." data-i18n="[title]If the generated message is shorter than these many characters, trigger an auto-swipe">
4670 <small data-i18n="Blacklisted words">Blacklisted words</small>4686 <small data-i18n="Blacklisted words">Blacklisted words</small>
4671 <div class="auto_swipe">4687 <div class="auto_swipe">
4672 <textarea id="auto_swipe_blacklist" name="auto_swipe_blacklist" data-i18n="[placeholder]words you dont want generated separated by comma ','" placeholder="words you don't want generated separated by comma ','" class="text_pole textarea_compact" value="" autocomplete="off" rows="3"></textarea>4688 <textarea id="auto_swipe_blacklist" name="auto_swipe_blacklist" data-i18n="[placeholder]words you dont want generated separated by comma ','" placeholder="words you don't want generated separated by comma ','" class="text_pole textarea_compact" value="" autocomplete="off" rows="3"></textarea>
@@ -6890,8 +6906,8 @@
6890 </div>6906 </div>
6891 <div id="form_sheld">6907 <div id="form_sheld">
6892 <div id="dialogue_del_mes">6908 <div id="dialogue_del_mes">
6893 <div id="dialogue_del_mes_ok" class="menu_button">Delete</div>6909 <div id="dialogue_del_mes_ok" data-i18n="Delete" class="menu_button">Delete</div>
6894 <div id="dialogue_del_mes_cancel" class="menu_button">Cancel</div>6910 <div id="dialogue_del_mes_cancel" data-i18n="Cancel" class="menu_button">Cancel</div>
6895 </div>6911 </div>
6896 <div id="send_form" class="no-connection">6912 <div id="send_form" class="no-connection">
6897 <form id="file_form" class="wide100p displayNone">6913 <form id="file_form" class="wide100p displayNone">
public/lib/eventemitter.js+42 -1
@@ -24,10 +24,22 @@ if (typeof Array.prototype.indexOf === 'function') {
2424
2525
26/* Polyfill EventEmitter. */26/* Polyfill EventEmitter. */
27var EventEmitter = function () {27/**
28 * Creates an event emitter.
29 * @param {string[]} autoFireAfterEmit Auto-fire event names
30 */
31var EventEmitter = function (autoFireAfterEmit = []) {
28 this.events = {};32 this.events = {};
33 this.autoFireLastArgs = new Map();
34 this.autoFireAfterEmit = new Set(autoFireAfterEmit);
29};35};
3036
37/**
38 * Adds a listener to an event.
39 * @param {string} event Event name
40 * @param {function} listener Event listener
41 * @returns
42 */
31EventEmitter.prototype.on = function (event, listener) {43EventEmitter.prototype.on = function (event, listener) {
32 // Unknown event used by external libraries?44 // Unknown event used by external libraries?
33 if (event === undefined) {45 if (event === undefined) {
@@ -40,6 +52,10 @@ EventEmitter.prototype.on = function (event, listener) {
40 }52 }
4153
42 this.events[event].push(listener);54 this.events[event].push(listener);
55
56 if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
57 listener.apply(this, this.autoFireLastArgs.get(event));
58 }
43};59};
4460
45/**61/**
@@ -60,6 +76,10 @@ EventEmitter.prototype.makeLast = function (event, listener) {
60 }76 }
6177
62 events.push(listener);78 events.push(listener);
79
80 if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
81 listener.apply(this, this.autoFireLastArgs.get(event));
82 }
63}83}
6484
65/**85/**
@@ -80,8 +100,17 @@ EventEmitter.prototype.makeFirst = function (event, listener) {
80 }100 }
81101
82 events.unshift(listener);102 events.unshift(listener);
103
104 if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
105 listener.apply(this, this.autoFireLastArgs.get(event));
106 }
83}107}
84108
109/**
110 * Removes a listener from an event.
111 * @param {string} event Event name
112 * @param {function} listener Event listener
113 */
85EventEmitter.prototype.removeListener = function (event, listener) {114EventEmitter.prototype.removeListener = function (event, listener) {
86 var idx;115 var idx;
87116
@@ -94,6 +123,10 @@ EventEmitter.prototype.removeListener = function (event, listener) {
94 }123 }
95};124};
96125
126/**
127 * Emits an event with optional arguments.
128 * @param {string} event Event name
129 */
97EventEmitter.prototype.emit = async function (event) {130EventEmitter.prototype.emit = async function (event) {
98 let args = [].slice.call(arguments, 1);131 let args = [].slice.call(arguments, 1);
99 if (localStorage.getItem('eventTracing') === 'true') {132 if (localStorage.getItem('eventTracing') === 'true') {
@@ -118,6 +151,10 @@ EventEmitter.prototype.emit = async function (event) {
118 }151 }
119 }152 }
120 }153 }
154
155 if (this.autoFireAfterEmit.has(event)) {
156 this.autoFireLastArgs.set(event, args);
157 }
121};158};
122159
123EventEmitter.prototype.emitAndWait = function (event) {160EventEmitter.prototype.emitAndWait = function (event) {
@@ -144,6 +181,10 @@ EventEmitter.prototype.emitAndWait = function (event) {
144 }181 }
145 }182 }
146 }183 }
184
185 if (this.autoFireAfterEmit.has(event)) {
186 this.autoFireLastArgs.set(event, args);
187 }
147};188};
148189
149EventEmitter.prototype.once = function (event, listener) {190EventEmitter.prototype.once = function (event, listener) {
public/locales/ar-sa.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "تفضيل التعليمات من بطاقة الشخصية",633 "Prefer Character Card Prompt": "تفضيل التعليمات من بطاقة الشخصية",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "إذا تم التحقق وكانت بطاقة الشخصية تحتوي على تجاوز للكسر (تعليمات تاريخ المشاركة)، استخدم ذلك بدلاً من ذلك",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "إذا تم التحقق وكانت بطاقة الشخصية تحتوي على تجاوز للكسر (تعليمات تاريخ المشاركة)، استخدم ذلك بدلاً من ذلك",
635 "Prefer Character Card Jailbreak": "تفضيل كسر الحصار من بطاقة الشخصية",635 "Prefer Character Card Jailbreak": "تفضيل كسر الحصار من بطاقة الشخصية",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "تجنب اقتصاص صور الأحرف المستوردة وتغيير حجمها. عند إيقاف التشغيل، قم بالقص/تغيير الحجم إلى 512 × 768.",636 "never_resize_avatars_tooltip": "تجنب اقتصاص صور الأحرف المستوردة وتغيير حجمها. عند إيقاف التشغيل، قم بالقص/تغيير الحجم إلى 512 × 768.",
637 "Never resize avatars": "لا تغيير حجم الصور الرمزية أبدًا",637 "Never resize avatars": "لا تغيير حجم الصور الرمزية أبدًا",
638 "Show actual file names on the disk, in the characters list display only": "عرض الأسماء الفعلية للملفات على القرص، في عرض قائمة الشخصيات فقط",638 "Show actual file names on the disk, in the characters list display only": "عرض الأسماء الفعلية للملفات على القرص، في عرض قائمة الشخصيات فقط",
639 "Show avatar filenames": "عرض أسماء ملفات الصور الرمزية",639 "Show avatar filenames": "عرض أسماء ملفات الصور الرمزية",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "السحب التلقائي",709 "Auto-swipe": "السحب التلقائي",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "تمكين وظيفة السحب التلقائي. الإعدادات في هذا القسم تؤثر فقط عند تمكين السحب التلقائي",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "تمكين وظيفة السحب التلقائي. الإعدادات في هذا القسم تؤثر فقط عند تمكين السحب التلقائي",
711 "Minimum generated message length": "الحد الأدنى لطول الرسالة المولدة",711 "Minimum generated message length": "الحد الأدنى لطول الرسالة المولدة",
712 "If the generated message is shorter than this, trigger an auto-swipe": "إذا كانت الرسالة المولدة أقصر من هذا، فتحريض السحب التلقائي",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "إذا كانت الرسالة المولدة أقصر من هذا، فتحريض السحب التلقائي",
713 "Blacklisted words": "الكلمات الممنوعة",713 "Blacklisted words": "الكلمات الممنوعة",
714 "words you dont want generated separated by comma ','": "الكلمات التي لا تريد توليدها مفصولة بفاصلة ','",714 "words you dont want generated separated by comma ','": "الكلمات التي لا تريد توليدها مفصولة بفاصلة ','",
715 "Blacklisted word count to swipe": "عدد الكلمات الممنوعة للسحب",715 "Blacklisted word count to swipe": "عدد الكلمات الممنوعة للسحب",
public/locales/de-de.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "Bevorzuge Charakterkarten-Prompt",633 "Prefer Character Card Prompt": "Bevorzuge Charakterkarten-Prompt",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Wenn aktiviert und die Charakterkarte eine Jailbreak-Überschreibung enthält (Post-History-Instruction), verwende stattdessen diese",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Wenn aktiviert und die Charakterkarte eine Jailbreak-Überschreibung enthält (Post-History-Instruction), verwende stattdessen diese",
635 "Prefer Character Card Jailbreak": "Bevorzuge Charakterkarten-Jailbreak",635 "Prefer Character Card Jailbreak": "Bevorzuge Charakterkarten-Jailbreak",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Vermeiden Sie das Zuschneiden und Ändern der Größe importierter Zeichenbilder. Wenn deaktiviert, wird die Größe auf 512 x 768 zugeschnitten/angepasst.",636 "never_resize_avatars_tooltip": "Vermeiden Sie das Zuschneiden und Ändern der Größe importierter Zeichenbilder. Wenn deaktiviert, wird die Größe auf 512 x 768 zugeschnitten/angepasst.",
637 "Never resize avatars": "Avatare niemals verkleinern",637 "Never resize avatars": "Avatare niemals verkleinern",
638 "Show actual file names on the disk, in the characters list display only": "Zeige tatsächliche Dateinamen auf der Festplatte, nur in der Anzeige der Charakterliste",638 "Show actual file names on the disk, in the characters list display only": "Zeige tatsächliche Dateinamen auf der Festplatte, nur in der Anzeige der Charakterliste",
639 "Show avatar filenames": "Avatar-Dateinamen anzeigen",639 "Show avatar filenames": "Avatar-Dateinamen anzeigen",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "Automatisches Wischen",709 "Auto-swipe": "Automatisches Wischen",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Aktiviere die Auto-Wisch-Funktion. Einstellungen in diesem Abschnitt haben nur dann Auswirkungen, wenn das automatische Wischen aktiviert ist",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Aktiviere die Auto-Wisch-Funktion. Einstellungen in diesem Abschnitt haben nur dann Auswirkungen, wenn das automatische Wischen aktiviert ist",
711 "Minimum generated message length": "Minimale generierte Nachrichtenlänge",711 "Minimum generated message length": "Minimale generierte Nachrichtenlänge",
712 "If the generated message is shorter than this, trigger an auto-swipe": "Wenn die generierte Nachricht kürzer ist als diese, löse automatisches Wischen aus",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Wenn die generierte Nachricht kürzer ist als diese, löse automatisches Wischen aus",
713 "Blacklisted words": "Verbotene Wörter",713 "Blacklisted words": "Verbotene Wörter",
714 "words you dont want generated separated by comma ','": "Wörter, die du nicht generiert haben möchtest, durch Komma ',' getrennt",714 "words you dont want generated separated by comma ','": "Wörter, die du nicht generiert haben möchtest, durch Komma ',' getrennt",
715 "Blacklisted word count to swipe": "Anzahl der verbotenen Wörter, um zu wischen",715 "Blacklisted word count to swipe": "Anzahl der verbotenen Wörter, um zu wischen",
public/locales/es-es.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "Preferir Indicaciones en Tarjeta de Personaje",633 "Prefer Character Card Prompt": "Preferir Indicaciones en Tarjeta de Personaje",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Si está marcado y la tarjeta de personaje contiene una anulación de jailbreak (Instrucciones Post Historial), usar eso en su lugar",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Si está marcado y la tarjeta de personaje contiene una anulación de jailbreak (Instrucciones Post Historial), usar eso en su lugar",
635 "Prefer Character Card Jailbreak": "Preferir Jailbreak en Tarjeta de Personaje",635 "Prefer Character Card Jailbreak": "Preferir Jailbreak en Tarjeta de Personaje",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Evite recortar y cambiar el tamaño de las imágenes de personajes importados. Cuando esté desactivado, recorte/cambie el tamaño a 512x768.",636 "never_resize_avatars_tooltip": "Evite recortar y cambiar el tamaño de las imágenes de personajes importados. Cuando esté desactivado, recorte/cambie el tamaño a 512x768.",
637 "Never resize avatars": "Nunca redimensionar avatares",637 "Never resize avatars": "Nunca redimensionar avatares",
638 "Show actual file names on the disk, in the characters list display only": "Mostrar nombres de archivo reales en el disco, solo en la visualización de la lista de personajes",638 "Show actual file names on the disk, in the characters list display only": "Mostrar nombres de archivo reales en el disco, solo en la visualización de la lista de personajes",
639 "Show avatar filenames": "Mostrar nombres de archivo de avatares",639 "Show avatar filenames": "Mostrar nombres de archivo de avatares",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "Deslizamiento automático",709 "Auto-swipe": "Deslizamiento automático",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Habilitar la función de deslizamiento automático. La configuración en esta sección solo tiene efecto cuando el deslizamiento automático está habilitado",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Habilitar la función de deslizamiento automático. La configuración en esta sección solo tiene efecto cuando el deslizamiento automático está habilitado",
711 "Minimum generated message length": "Longitud mínima del mensaje generado",711 "Minimum generated message length": "Longitud mínima del mensaje generado",
712 "If the generated message is shorter than this, trigger an auto-swipe": "Si el mensaje generado es más corto que esto, activar un deslizamiento automático",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Si el mensaje generado es más corto que esto, activar un deslizamiento automático",
713 "Blacklisted words": "Palabras prohibidas",713 "Blacklisted words": "Palabras prohibidas",
714 "words you dont want generated separated by comma ','": "palabras que no desea generar separadas por coma ','",714 "words you dont want generated separated by comma ','": "palabras que no desea generar separadas por coma ','",
715 "Blacklisted word count to swipe": "Número de palabras prohibidas para deslizar",715 "Blacklisted word count to swipe": "Número de palabras prohibidas para deslizar",
public/locales/fr-fr.json+3 -4
@@ -581,7 +581,7 @@
581 "Advanced Character Search": "Recherche de personnage avancée",581 "Advanced Character Search": "Recherche de personnage avancée",
582 "If checked and the character card contains a prompt override (System Prompt), use that instead": "Si cochée et si la carte de personnage contient un prompt de remplacement (prompt système), l'utiliser à la place",582 "If checked and the character card contains a prompt override (System Prompt), use that instead": "Si cochée et si la carte de personnage contient un prompt de remplacement (prompt système), l'utiliser à la place",
583 "Prefer Character Card Prompt": "Préférer le prompt du personnage",583 "Prefer Character Card Prompt": "Préférer le prompt du personnage",
584 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Évitez de recadrer et de redimensionner les images de personnages importés. Lorsqu'il est désactivé, recadrez/redimensionnez à 512 x 768.",584 "never_resize_avatars_tooltip": "Évitez de recadrer et de redimensionner les images de personnages importés. Lorsqu'il est désactivé, recadrez/redimensionnez à 512 x 768.",
585 "Never resize avatars": "Ne jamais redimensionner les avatars",585 "Never resize avatars": "Ne jamais redimensionner les avatars",
586 "Show actual file names on the disk, in the characters list display only": "Afficher les noms de fichier réels sur le disque, dans l'affichage de la liste de personnages uniquement",586 "Show actual file names on the disk, in the characters list display only": "Afficher les noms de fichier réels sur le disque, dans l'affichage de la liste de personnages uniquement",
587 "Show avatar filenames": "Afficher les noms de fichier des avatars",587 "Show avatar filenames": "Afficher les noms de fichier des avatars",
@@ -656,7 +656,7 @@
656 "Auto-swipe": "Balayage automatique",656 "Auto-swipe": "Balayage automatique",
657 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Activer la fonction de balayage automatique. Les paramètres de cette section n'ont d'effet que lorsque le balayage automatique est activé",657 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Activer la fonction de balayage automatique. Les paramètres de cette section n'ont d'effet que lorsque le balayage automatique est activé",
658 "Minimum generated message length": "Longueur minimale du message généré",658 "Minimum generated message length": "Longueur minimale du message généré",
659 "If the generated message is shorter than this, trigger an auto-swipe": "Si le message généré est plus court que cela, déclenchez un balayage automatique",659 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Si le message généré est plus court que cela, déclenchez un balayage automatique",
660 "Blacklisted words": "Mots en liste noire",660 "Blacklisted words": "Mots en liste noire",
661 "words you dont want generated separated by comma ','": "mots que vous ne voulez pas générer séparés par des virgules ','",661 "words you dont want generated separated by comma ','": "mots que vous ne voulez pas générer séparés par des virgules ','",
662 "Blacklisted word count to swipe": "Nombre de mots en liste noire pour balayer",662 "Blacklisted word count to swipe": "Nombre de mots en liste noire pour balayer",
@@ -1485,7 +1485,7 @@
1485 "(disabled when max recursion steps are used)": "(désactivé lorsque le nombre maximum de pas de récursivité est utilisé)",1485 "(disabled when max recursion steps are used)": "(désactivé lorsque le nombre maximum de pas de récursivité est utilisé)",
1486 "Cap the number of entry activation recursions": "Plafonner le nombre de récursions d'activation d'entrée",1486 "Cap the number of entry activation recursions": "Plafonner le nombre de récursions d'activation d'entrée",
1487 "Max Recursion Steps": "Nombre maximal d'étapes de récursivité",1487 "Max Recursion Steps": "Nombre maximal d'étapes de récursivité",
1488 "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc\\n(disabled when min activations are used)": "0 = illimité, 1 = scanne une fois et ne récure pas, 2 = scanne une fois et récure une fois, etc.\n(désactivé lorsque des activations minimales sont utilisées)",1488 "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc": "0 = illimité, 1 = scanne une fois et ne récure pas, 2 = scanne une fois et récure une fois, etc.\n(désactivé lorsque des activations minimales sont utilisées)",
1489 "Include names with each message into the context for scanning": "Inclure les noms dans chaque message dans le contexte pour l'analyse.",1489 "Include names with each message into the context for scanning": "Inclure les noms dans chaque message dans le contexte pour l'analyse.",
1490 "Apply current sorting as Order": "Appliquer le tri actuel comme ordre",1490 "Apply current sorting as Order": "Appliquer le tri actuel comme ordre",
1491 "Display swipe numbers for all messages, not just the last.": "Afficher le nombre de balayage sur tous les messages, et pas seulement le dernier.",1491 "Display swipe numbers for all messages, not just the last.": "Afficher le nombre de balayage sur tous les messages, et pas seulement le dernier.",
@@ -1602,7 +1602,6 @@
1602 "Character Expressions": "Expressions de personnages",1602 "Character Expressions": "Expressions de personnages",
1603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",1603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",
1604 "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",1604 "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",
1605 "Image Type - talkinghead (extras)": "Type d'image - talkinghead (extras)",
1606 "Classifier API": "API de classification",1605 "Classifier API": "API de classification",
1607 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",1606 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",
1608 "Main API": "API principale",1607 "Main API": "API principale",
public/locales/is-is.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "Kosstu kvenkortu fyrirspurn",633 "Prefer Character Card Prompt": "Kosstu kvenkortu fyrirspurn",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Ef merkt er og kortið inniheldur fangabrotsskil, notaðu það í staðinn",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Ef merkt er og kortið inniheldur fangabrotsskil, notaðu það í staðinn",
635 "Prefer Character Card Jailbreak": "Kosstu kvenkortu fangabrot",635 "Prefer Character Card Jailbreak": "Kosstu kvenkortu fangabrot",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Forðastu að klippa og breyta stærð innfluttra stafamynda. Þegar slökkt er á því skaltu skera/breyta stærð í 512x768.",636 "never_resize_avatars_tooltip": "Forðastu að klippa og breyta stærð innfluttra stafamynda. Þegar slökkt er á því skaltu skera/breyta stærð í 512x768.",
637 "Never resize avatars": "Aldrei breyta stærðinni á merkjum",637 "Never resize avatars": "Aldrei breyta stærðinni á merkjum",
638 "Show actual file names on the disk, in the characters list display only": "Sýna raunveruleg nöfn skráa á diskinum, í lista yfir persónur sýna aðeins",638 "Show actual file names on the disk, in the characters list display only": "Sýna raunveruleg nöfn skráa á diskinum, í lista yfir persónur sýna aðeins",
639 "Show avatar filenames": "Sýna nöfn merkja",639 "Show avatar filenames": "Sýna nöfn merkja",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "Sjálfvirkur sveip",709 "Auto-swipe": "Sjálfvirkur sveip",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Virkjaðu sjálfvirka sveiflugerð. Stillingar í þessum hluta hafa aðeins áhrif þegar sjálfvirkur sveiflugerð er virk",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Virkjaðu sjálfvirka sveiflugerð. Stillingar í þessum hluta hafa aðeins áhrif þegar sjálfvirkur sveiflugerð er virk",
711 "Minimum generated message length": "Lágmarks lengd á mynduðum skilaboðum",711 "Minimum generated message length": "Lágmarks lengd á mynduðum skilaboðum",
712 "If the generated message is shorter than this, trigger an auto-swipe": "Ef mynduðu skilaboðin eru styttri en þessi, kallaðu fram sjálfvirkar sveiflugerðar",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Ef mynduðu skilaboðin eru styttri en þessi, kallaðu fram sjálfvirkar sveiflugerðar",
713 "Blacklisted words": "Svört orð",713 "Blacklisted words": "Svört orð",
714 "words you dont want generated separated by comma ','": "orð sem þú vilt ekki að framleiða aðskilin með kommu ','",714 "words you dont want generated separated by comma ','": "orð sem þú vilt ekki að framleiða aðskilin með kommu ','",
715 "Blacklisted word count to swipe": "Fjöldi svörtra orða til að sveipa",715 "Blacklisted word count to swipe": "Fjöldi svörtra orða til að sveipa",
public/locales/it-it.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "Preferisci Prompt della Scheda Personaggio",633 "Prefer Character Card Prompt": "Preferisci Prompt della Scheda Personaggio",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Se selezionato e la scheda del personaggio contiene una sovrascrittura jailbreak (Istruzione Storico Post), usalo invece",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Se selezionato e la scheda del personaggio contiene una sovrascrittura jailbreak (Istruzione Storico Post), usalo invece",
635 "Prefer Character Card Jailbreak": "Preferisci Jailbreak della Scheda Personaggio",635 "Prefer Character Card Jailbreak": "Preferisci Jailbreak della Scheda Personaggio",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Evita di ritagliare e ridimensionare le immagini dei personaggi importati. Quando è disattivato, ritaglia/ridimensiona a 512x768.",636 "never_resize_avatars_tooltip": "Evita di ritagliare e ridimensionare le immagini dei personaggi importati. Quando è disattivato, ritaglia/ridimensiona a 512x768.",
637 "Never resize avatars": "Non ridimensionare mai gli avatar",637 "Never resize avatars": "Non ridimensionare mai gli avatar",
638 "Show actual file names on the disk, in the characters list display only": "Mostra i nomi file effettivi sul disco, solo nella visualizzazione dell'elenco dei personaggi",638 "Show actual file names on the disk, in the characters list display only": "Mostra i nomi file effettivi sul disco, solo nella visualizzazione dell'elenco dei personaggi",
639 "Show avatar filenames": "Mostra nomi file avatar",639 "Show avatar filenames": "Mostra nomi file avatar",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "Auto-swipe",709 "Auto-swipe": "Auto-swipe",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Abilita la funzione di auto-swipe. Le impostazioni in questa sezione hanno effetto solo quando l'auto-swipe è abilitato",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Abilita la funzione di auto-swipe. Le impostazioni in questa sezione hanno effetto solo quando l'auto-swipe è abilitato",
711 "Minimum generated message length": "Lunghezza minima del messaggio generato",711 "Minimum generated message length": "Lunghezza minima del messaggio generato",
712 "If the generated message is shorter than this, trigger an auto-swipe": "Se il messaggio generato è più breve di questo, attiva un'automatica rimozione",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Se il messaggio generato è più breve di questo, attiva un'automatica rimozione",
713 "Blacklisted words": "Parole in blacklist",713 "Blacklisted words": "Parole in blacklist",
714 "words you dont want generated separated by comma ','": "parole che non vuoi generate separate da virgola ','",714 "words you dont want generated separated by comma ','": "parole che non vuoi generate separate da virgola ','",
715 "Blacklisted word count to swipe": "Numero di parole in blacklist per attivare un'automatica rimozione",715 "Blacklisted word count to swipe": "Numero di parole in blacklist per attivare un'automatica rimozione",
public/locales/ja-jp.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "キャラクターカードのプロンプトを優先",633 "Prefer Character Card Prompt": "キャラクターカードのプロンプトを優先",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "チェックされていてキャラクターカードにジェイルブレイクオーバーライド(投稿履歴指示)が含まれている場合、それを代わりに使用します",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "チェックされていてキャラクターカードにジェイルブレイクオーバーライド(投稿履歴指示)が含まれている場合、それを代わりに使用します",
635 "Prefer Character Card Jailbreak": "キャラクターカードのJailbreakを優先",635 "Prefer Character Card Jailbreak": "キャラクターカードのJailbreakを優先",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "インポートした文字画像の切り取りやサイズ変更を避けます。オフにすると、512x768 に切り取り/サイズ変更されます。",636 "never_resize_avatars_tooltip": "インポートした文字画像の切り取りやサイズ変更を避けます。オフにすると、512x768 に切り取り/サイズ変更されます。",
637 "Never resize avatars": "アバターを常にリサイズしない",637 "Never resize avatars": "アバターを常にリサイズしない",
638 "Show actual file names on the disk, in the characters list display only": "ディスク上の実際のファイル名を表示します。キャラクターリストの表示にのみ",638 "Show actual file names on the disk, in the characters list display only": "ディスク上の実際のファイル名を表示します。キャラクターリストの表示にのみ",
639 "Show avatar filenames": "アバターのファイル名を表示",639 "Show avatar filenames": "アバターのファイル名を表示",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "オートスワイプ",709 "Auto-swipe": "オートスワイプ",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "自動スワイプ機能を有効にします。このセクションの設定は、自動スワイプが有効になっている場合にのみ効果があります",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "自動スワイプ機能を有効にします。このセクションの設定は、自動スワイプが有効になっている場合にのみ効果があります",
711 "Minimum generated message length": "生成されたメッセージの最小長",711 "Minimum generated message length": "生成されたメッセージの最小長",
712 "If the generated message is shorter than this, trigger an auto-swipe": "生成されたメッセージがこれよりも短い場合、自動スワイプをトリガーします",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "生成されたメッセージがこれよりも短い場合、自動スワイプをトリガーします",
713 "Blacklisted words": "ブラックリストされた単語",713 "Blacklisted words": "ブラックリストされた単語",
714 "words you dont want generated separated by comma ','": "コンマ ',' で区切られた生成したくない単語",714 "words you dont want generated separated by comma ','": "コンマ ',' で区切られた生成したくない単語",
715 "Blacklisted word count to swipe": "スワイプするブラックリストされた単語の数",715 "Blacklisted word count to swipe": "スワイプするブラックリストされた単語の数",
public/locales/ko-kr.json+2 -3
@@ -646,7 +646,7 @@
646 "Prefer Character Card Prompt": "캐릭터 카드 프롬프트 선호",646 "Prefer Character Card Prompt": "캐릭터 카드 프롬프트 선호",
647 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "선택되어 있고 캐릭터 카드에 (Post-History 지시)탈옥 재정의가 포함 된 경우, 그것을 대신 사용합니다.",647 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "선택되어 있고 캐릭터 카드에 (Post-History 지시)탈옥 재정의가 포함 된 경우, 그것을 대신 사용합니다.",
648 "Prefer Character Card Jailbreak": "캐릭터 카드 탈옥 선호",648 "Prefer Character Card Jailbreak": "캐릭터 카드 탈옥 선호",
649 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "가져온 캐릭터 이미지를 자르거나 크기를 조정하지 마세요. 꺼져 있으면 512x768로 자르거나 크기를 조정합니다.",649 "never_resize_avatars_tooltip": "가져온 캐릭터 이미지를 자르거나 크기를 조정하지 마세요. 꺼져 있으면 512x768로 자르거나 크기를 조정합니다.",
650 "Never resize avatars": "아바타 크기 변경하지 않음",650 "Never resize avatars": "아바타 크기 변경하지 않음",
651 "Show actual file names on the disk, in the characters list display only": "실제 파일 이름을 디스크에 표시하며 캐릭터 목록 디스플레이에만",651 "Show actual file names on the disk, in the characters list display only": "실제 파일 이름을 디스크에 표시하며 캐릭터 목록 디스플레이에만",
652 "Show avatar filenames": "아바타 파일 이름 표시",652 "Show avatar filenames": "아바타 파일 이름 표시",
@@ -724,7 +724,7 @@
724 "Auto-swipe": "자동 스와이프",724 "Auto-swipe": "자동 스와이프",
725 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "자동 스와이프 기능을 활성화합니다. 이 섹션의 설정은 자동 스와이프가 활성화되었을 때만 영향을 미칩니다",725 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "자동 스와이프 기능을 활성화합니다. 이 섹션의 설정은 자동 스와이프가 활성화되었을 때만 영향을 미칩니다",
726 "Minimum generated message length": "생성된 메시지 최소 길이",726 "Minimum generated message length": "생성된 메시지 최소 길이",
727 "If the generated message is shorter than this, trigger an auto-swipe": "생성된 메시지가이보다 짧으면 자동 스와이프를 트리거합니다",727 "If the generated message is shorter than these many characters, trigger an auto-swipe": "생성된 메시지가이보다 짧으면 자동 스와이프를 트리거합니다",
728 "Blacklisted words": "금지어",728 "Blacklisted words": "금지어",
729 "words you dont want generated separated by comma ','": "쉼표로 구분된 생성하지 않으려는 단어",729 "words you dont want generated separated by comma ','": "쉼표로 구분된 생성하지 않으려는 단어",
730 "Blacklisted word count to swipe": "스와이프할 금지어 개수",730 "Blacklisted word count to swipe": "스와이프할 금지어 개수",
@@ -1467,7 +1467,6 @@
1467 "menu within": "내의 메뉴",1467 "menu within": "내의 메뉴",
1468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",1468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",
1469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",1469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",
1470 "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)",
1471 "Classifier API": "분류를 위한 API",1470 "Classifier API": "분류를 위한 API",
1472 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",1471 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",
1473 "Local": "로컬",1472 "Local": "로컬",
public/locales/nl-nl.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "Voorkeur karakterkaart prompt",633 "Prefer Character Card Prompt": "Voorkeur karakterkaart prompt",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Als aangevinkt en de karakterkaart bevat een jailbreak-override (Post History Instruction), gebruik die in plaats daarvan",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Als aangevinkt en de karakterkaart bevat een jailbreak-override (Post History Instruction), gebruik die in plaats daarvan",
635 "Prefer Character Card Jailbreak": "Voorkeur karakterkaart jailbreak",635 "Prefer Character Card Jailbreak": "Voorkeur karakterkaart jailbreak",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Vermijd het bijsnijden en vergroten/verkleinen van geïmporteerde karakterafbeeldingen. Indien uitgeschakeld, bijsnijden/formaat wijzigen naar 512 x 768.",636 "never_resize_avatars_tooltip": "Vermijd het bijsnijden en vergroten/verkleinen van geïmporteerde karakterafbeeldingen. Indien uitgeschakeld, bijsnijden/formaat wijzigen naar 512 x 768.",
637 "Never resize avatars": "Avatars nooit verkleinen",637 "Never resize avatars": "Avatars nooit verkleinen",
638 "Show actual file names on the disk, in the characters list display only": "Toon de werkelijke bestandsnamen op de schijf, alleen in de weergave van de lijst met personages",638 "Show actual file names on the disk, in the characters list display only": "Toon de werkelijke bestandsnamen op de schijf, alleen in de weergave van de lijst met personages",
639 "Show avatar filenames": "Toon avatar bestandsnamen",639 "Show avatar filenames": "Toon avatar bestandsnamen",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "Automatisch vegen",709 "Auto-swipe": "Automatisch vegen",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Schakel de automatische-vegen functie in. Instellingen in dit gedeelte hebben alleen effect wanneer automatisch vegen is ingeschakeld",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Schakel de automatische-vegen functie in. Instellingen in dit gedeelte hebben alleen effect wanneer automatisch vegen is ingeschakeld",
711 "Minimum generated message length": "Minimale gegenereerde berichtlengte",711 "Minimum generated message length": "Minimale gegenereerde berichtlengte",
712 "If the generated message is shorter than this, trigger an auto-swipe": "Als het gegenereerde bericht korter is dan dit, activeer dan een automatische veeg",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Als het gegenereerde bericht korter is dan dit, activeer dan een automatische veeg",
713 "Blacklisted words": "Verboden woorden",713 "Blacklisted words": "Verboden woorden",
714 "words you dont want generated separated by comma ','": "woorden die je niet gegenereerd wilt hebben gescheiden door komma ','",714 "words you dont want generated separated by comma ','": "woorden die je niet gegenereerd wilt hebben gescheiden door komma ','",
715 "Blacklisted word count to swipe": "Aantal verboden woorden om te vegen",715 "Blacklisted word count to swipe": "Aantal verboden woorden om te vegen",
public/locales/pt-pt.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "Preferir Prompt do Cartão de Personagem",633 "Prefer Character Card Prompt": "Preferir Prompt do Cartão de Personagem",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Se marcado e o cartão de personagem contiver uma substituição de jailbreak (Instrução de Histórico de Postagens), use isso em vez disso",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Se marcado e o cartão de personagem contiver uma substituição de jailbreak (Instrução de Histórico de Postagens), use isso em vez disso",
635 "Prefer Character Card Jailbreak": "Preferir Jailbreak do Cartão de Personagem",635 "Prefer Character Card Jailbreak": "Preferir Jailbreak do Cartão de Personagem",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Evite cortar e redimensionar imagens de personagens importados. Quando desativado, corte/redimensione para 512x768.",636 "never_resize_avatars_tooltip": "Evite cortar e redimensionar imagens de personagens importados. Quando desativado, corte/redimensione para 512x768.",
637 "Never resize avatars": "Nunca redimensionar avatares",637 "Never resize avatars": "Nunca redimensionar avatares",
638 "Show actual file names on the disk, in the characters list display only": "Mostrar nomes de arquivo reais no disco, apenas na exibição da lista de personagens",638 "Show actual file names on the disk, in the characters list display only": "Mostrar nomes de arquivo reais no disco, apenas na exibição da lista de personagens",
639 "Show avatar filenames": "Mostrar nomes de arquivo de avatar",639 "Show avatar filenames": "Mostrar nomes de arquivo de avatar",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "Auto-swipe",709 "Auto-swipe": "Auto-swipe",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Ativar a função de auto-swipe. As configurações nesta seção só têm efeito quando o auto-swipe está ativado",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Ativar a função de auto-swipe. As configurações nesta seção só têm efeito quando o auto-swipe está ativado",
711 "Minimum generated message length": "Comprimento mínimo da mensagem gerada",711 "Minimum generated message length": "Comprimento mínimo da mensagem gerada",
712 "If the generated message is shorter than this, trigger an auto-swipe": "Se a mensagem gerada for mais curta que isso, acione um auto-swipe",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Se a mensagem gerada for mais curta que isso, acione um auto-swipe",
713 "Blacklisted words": "Palavras proibidas",713 "Blacklisted words": "Palavras proibidas",
714 "words you dont want generated separated by comma ','": "palavras que você não quer geradas separadas por vírgula ','",714 "words you dont want generated separated by comma ','": "palavras que você não quer geradas separadas por vírgula ','",
715 "Blacklisted word count to swipe": "Contagem de palavras proibidas para swipe",715 "Blacklisted word count to swipe": "Contagem de palavras proibidas para swipe",
public/locales/ru-ru.json+74 -14
@@ -195,7 +195,7 @@
195 "Yes": "Да",195 "Yes": "Да",
196 "No": "Нет",196 "No": "Нет",
197 "Context %": "Процент контекста",197 "Context %": "Процент контекста",
198 "Budget Cap": "Бюджетный лимит",198 "Budget Cap": "Лимит бюджета",
199 "(0 = disabled)": "(0 = отключено)",199 "(0 = disabled)": "(0 = отключено)",
200 "None": "Отсутствует",200 "None": "Отсутствует",
201 "User Settings": "Настройки пользователя",201 "User Settings": "Настройки пользователя",
@@ -426,7 +426,7 @@
426 "Requests logprobs from the API for the Token Probabilities feature": "Запросить логпробы из API для функции Token Probabilities.",426 "Requests logprobs from the API for the Token Probabilities feature": "Запросить логпробы из API для функции Token Probabilities.",
427 "Automatically reject and re-generate AI message based on configurable criteria": "Автоматическое отклонение и повторная генерация сообщений AI на основе настраиваемых критериев.",427 "Automatically reject and re-generate AI message based on configurable criteria": "Автоматическое отклонение и повторная генерация сообщений AI на основе настраиваемых критериев.",
428 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Включить авто-свайп. Настройки в этом разделе действуют только при включенном авто-свайпе.",428 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Включить авто-свайп. Настройки в этом разделе действуют только при включенном авто-свайпе.",
429 "If the generated message is shorter than this, trigger an auto-swipe": "Если сгенерированное сообщение короче этого значения, срабатывает авто-свайп.",429 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Если сгенерированное сообщение короче этого значения, срабатывает авто-свайп.",
430 "Reload and redraw the currently open chat": "Перезагрузить и перерисовать открытый в данный момент чат.",430 "Reload and redraw the currently open chat": "Перезагрузить и перерисовать открытый в данный момент чат.",
431 "Auto-Expand Message Actions": "Развернуть действия",431 "Auto-Expand Message Actions": "Развернуть действия",
432 "Persona Management": "Управление персоной",432 "Persona Management": "Управление персоной",
@@ -575,10 +575,10 @@
575 "Characters sorting order": "Порядок сортировки персонажей",575 "Characters sorting order": "Порядок сортировки персонажей",
576 "Remove": "Убрать",576 "Remove": "Убрать",
577 "Select a World Info file for": "Выбрать файл с миром для",577 "Select a World Info file for": "Выбрать файл с миром для",
578 "Primary Lorebook": "Основного лорбука",578 "Primary Lorebook": "Основной лорбук",
579 "A selected World Info will be bound to this character as its own Lorebook.": "Информация о мире будет привязана к персонажу как его собственный лорбук",579 "A selected World Info will be bound to this character as its own Lorebook.": "Информация о мире будет привязана к персонажу как его собственный лорбук.",
580 "When generating an AI reply, it will be combined with the entries from a global World Info selector.": "Когда ИИ генерирует ответ, он будет совмещён с записями из глобально выбранного мира",580 "When generating an AI reply, it will be combined with the entries from a global World Info selector.": "Когда ИИ генерирует ответ, он будет совмещён с записями из глобально выбранного мира.",
581 "Exporting a character would also export the selected Lorebook file embedded in the JSON data.": "При экспорте персонажа вместе с ним также выгрузится выбранный лорбук в виде JSON",581 "Exporting a character would also export the selected Lorebook file embedded in the JSON data.": "При экспорте персонажа вместе с ним также выгрузится выбранный лорбук в виде JSON.",
582 "Additional Lorebooks": "Вспомогательные лорбуки",582 "Additional Lorebooks": "Вспомогательные лорбуки",
583 "Associate one or more auxillary Lorebooks with this character.": "Привязать к этому персонажу один или больше вспомогательных лорбуков",583 "Associate one or more auxillary Lorebooks with this character.": "Привязать к этому персонажу один или больше вспомогательных лорбуков",
584 "NOTE: These choices are optional and won't be preserved on character export!": "ВНИМАНИЕ: эти выборы необязательные и не будут сохранены при экспорте персонажа!",584 "NOTE: These choices are optional and won't be preserved on character export!": "ВНИМАНИЕ: эти выборы необязательные и не будут сохранены при экспорте персонажа!",
@@ -593,7 +593,7 @@
593 "Prompt": "Промпт",593 "Prompt": "Промпт",
594 "Copy": "Скопировать",594 "Copy": "Скопировать",
595 "Confirm": "Подтвердить",595 "Confirm": "Подтвердить",
596 "Copy this message": "Скопировать сообщение",596 "Copy this message": "Продублировать сообщение",
597 "Delete this message": "Удалить сообщение",597 "Delete this message": "Удалить сообщение",
598 "Move message up": "Переместить сообщение вверх",598 "Move message up": "Переместить сообщение вверх",
599 "Move message down": "Переместить сообщение вниз",599 "Move message down": "Переместить сообщение вниз",
@@ -612,7 +612,7 @@
612 "Ask AI to write your message for you": "Попросить ИИ написать сообщение за вас",612 "Ask AI to write your message for you": "Попросить ИИ написать сообщение за вас",
613 "Continue the last message": "Продолжить текущее сообщение",613 "Continue the last message": "Продолжить текущее сообщение",
614 "Bind user name to that avatar": "Закрепить имя за этим аватаром",614 "Bind user name to that avatar": "Закрепить имя за этим аватаром",
615 "Select this as default persona for the new chats.": "Выберать эту Персону в качестве персоны по умолчанию для новых чатов.",615 "Select this as default persona for the new chats.": "Выбирать эту персону по умолчанию для всех новых чатов.",
616 "Change persona image": "Сменить аватар персоны",616 "Change persona image": "Сменить аватар персоны",
617 "Delete persona": "Удалить персону",617 "Delete persona": "Удалить персону",
618 "Reduced Motion": "Сокращение анимаций",618 "Reduced Motion": "Сокращение анимаций",
@@ -640,7 +640,7 @@
640 "Token Probabilities": "Вероятности токенов",640 "Token Probabilities": "Вероятности токенов",
641 "Close chat": "Закрыть чат",641 "Close chat": "Закрыть чат",
642 "Manage chat files": "Все чаты",642 "Manage chat files": "Все чаты",
643 "Import Extension From Git Repo": "Импортировать расширение из Git Repository",643 "Import Extension From Git Repo": "Импортировать расширение из Git-репозитория.",
644 "Install extension": "Установить расширение",644 "Install extension": "Установить расширение",
645 "Manage extensions": "Управление расширениями",645 "Manage extensions": "Управление расширениями",
646 "Tokens persona description": "Токенов",646 "Tokens persona description": "Токенов",
@@ -995,7 +995,7 @@
995 "Set your custom avatar.": "Установить аватарку",995 "Set your custom avatar.": "Установить аватарку",
996 "Remove your custom avatar.": "Сбросить аватарку",996 "Remove your custom avatar.": "Сбросить аватарку",
997 "Make a Snapshot": "Сделать снимок",997 "Make a Snapshot": "Сделать снимок",
998 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Не менять размер картинок у импортируемых персонажей. При отключении все картинки будут приводиться к размеру 512х768",998 "never_resize_avatars_tooltip": "Не менять размер картинок у импортируемых персонажей. При отключении все картинки будут приводиться к размеру 512х768",
999 "Char List Subheader": "Доп. заголовок в списке персонажей",999 "Char List Subheader": "Доп. заголовок в списке персонажей",
1000 "# Messages to Load": "Сколько сообщений загружать",1000 "# Messages to Load": "Сколько сообщений загружать",
1001 "(0 = All)": "(0 = все)",1001 "(0 = All)": "(0 = все)",
@@ -1122,7 +1122,7 @@
1122 "help_hotkeys_0": "Горячие клавиши",1122 "help_hotkeys_0": "Горячие клавиши",
1123 "You can browse a list of bundled characters in the": "Комплектных персонажей можно найти в меню",1123 "You can browse a list of bundled characters in the": "Комплектных персонажей можно найти в меню",
1124 "Download Extensions & Assets": "Загрузить расширения и ресурсы",1124 "Download Extensions & Assets": "Загрузить расширения и ресурсы",
1125 "menu within": "внутри этих кубиков",1125 "menu within": "в меню",
1126 "Assets URL": "URL с описанием ресурсов",1126 "Assets URL": "URL с описанием ресурсов",
1127 "Custom (OpenAI-compatible)": "Кастомный (совместимый с OpenAI)",1127 "Custom (OpenAI-compatible)": "Кастомный (совместимый с OpenAI)",
1128 "Custom Endpoint (Base URL)": "Кастомный эндпоинт (базовый URL)",1128 "Custom Endpoint (Base URL)": "Кастомный эндпоинт (базовый URL)",
@@ -1943,7 +1943,7 @@
1943 "and connect to an": "и подключитесь к",1943 "and connect to an": "и подключитесь к",
1944 "You can add more": "Можете добавить больше",1944 "You can add more": "Можете добавить больше",
1945 "from other websites": "с других сайтов.",1945 "from other websites": "с других сайтов.",
1946 "Go to the": "Загляните в",1946 "Go to the": "Заходите в",
1947 "to install additional features.": ", чтобы установить разные дополнительные ресурсы.",1947 "to install additional features.": ", чтобы установить разные дополнительные ресурсы.",
1948 "or_welcome": "; также доступен",1948 "or_welcome": "; также доступен",
1949 "Claude API Key": "Ключ от API Claude",1949 "Claude API Key": "Ключ от API Claude",
@@ -1958,7 +1958,7 @@
1958 "Save": "Сохранить",1958 "Save": "Сохранить",
1959 "Chat Lorebook": "Лорбук для чата",1959 "Chat Lorebook": "Лорбук для чата",
1960 "chat_world_template_txt": "Выбранный мир будет привязан к этому чату. Будет добавляться в промпт наряду с глобальным лорбуком и лором персонажа.",1960 "chat_world_template_txt": "Выбранный мир будет привязан к этому чату. Будет добавляться в промпт наряду с глобальным лорбуком и лором персонажа.",
1961 "world_button_title": "Лор персонажа\n\nНажмите, чтобы загрузить\nShift + клик, чтобы открыть диалог привязки мира",1961 "world_button_title": "Лор персонажа\n\nНажмите, чтобы загрузить\nShift + ЛКМ, чтобы открыть диалог привязки мира",
1962 "No auxillary Lorebooks set. Click here to select.": "Вспомогательный лорбук не выбран. Нажмите, чтобы выбрать.",1962 "No auxillary Lorebooks set. Click here to select.": "Вспомогательный лорбук не выбран. Нажмите, чтобы выбрать.",
1963 "ext_regex_user_input_desc": "Отправленные вами сообщения.",1963 "ext_regex_user_input_desc": "Отправленные вами сообщения.",
1964 "ext_regex_ai_input_desc": "Полученные от API ответы.",1964 "ext_regex_ai_input_desc": "Полученные от API ответы.",
@@ -2144,5 +2144,65 @@
2144 "Not connected to the API!": "Нет соединения с API!",2144 "Not connected to the API!": "Нет соединения с API!",
2145 "ext_type_system": "Это комплектное расширение. Его нельзя удалить, а обновляется оно вместе со всей системой.",2145 "ext_type_system": "Это комплектное расширение. Его нельзя удалить, а обновляется оно вместе со всей системой.",
2146 "Update all": "Обновить все",2146 "Update all": "Обновить все",
2147 "Close": "Закрыть"2147 "Close": "Закрыть",
2148 "Optional modules:": "Необязательные модули:",
2149 "Sort: Display Name": "Сортировать: по названию",
2150 "Sort: Loading Order": "Сортировать: в порядке загрузки",
2151 "Click to toggle": "Нажмите, чтобы включить или выключить",
2152 "Loading Asset List": "Загрузить список ресурсов",
2153 "Don't ask again for this URL": "Запомнить выбор для этого адреса",
2154 "Are you sure you want to connect to the following url?": "Вы точно хотите подключиться к этому адресу?",
2155 "All": "Всё",
2156 "Characters": "Персонажи",
2157 "Ambient sounds": "Звуковой эмбиент",
2158 "Blip sounds": "Звуки уведомлений",
2159 "Background music": "Фоновая музыка",
2160 "Search": "Поиск",
2161 "extension_install_1": "Чтобы загружать расширения из этого списка, у вас должен быть установлен ",
2162 "extension_install_2": ".",
2163 "extension_install_3": "Нажмите на иконку ",
2164 "extension_install_4": ", чтобы перейти в репозиторий расширения и получить более подробную информацию о нём.",
2165 "Extension repo/guide:": "Репозиторий расширения:",
2166 "Preview in browser": "Предпросмотр",
2167 "Adds a function tool": "Частично или полностью работает через вызов функций",
2168 "Tool": "Функции",
2169 "Move extension": "Переместить расширение",
2170 "ext_type_local": "Это локальное расширение, доступно только вам",
2171 "ext_type_global": "Это глобальное расширение, доступно всем пользователям",
2172 "Move": "Переместить",
2173 "Enter the Git URL of the extension to install": "Введите Git-адрес расширения",
2174 "Please be aware that using external extensions can have unintended side effects and may pose security risks. Always make sure you trust the source before importing an extension. We are not responsible for any damage caused by third-party extensions.": "помните, что используя расширения от сторонних авторов, вы можете подвергать систему опасности. Устанавливайте расширения только от проверенных разработчиков. Мы не несём ответственности за любой ущерб, причинённый сторонними расширениями.",
2175 "Disclaimer:": "Внимание:",
2176 "Example:": "Пример:",
2177 "context_derived": "Считывать из метаданных модели (по возможности)",
2178 "instruct_derived": "Считывать из метаданных модели (по возможности)",
2179 "Confirm token parsing with": "Чтобы убедиться в правильности выделения токенов, используйте",
2180 "Reasoning Effort": "Рассуждения",
2181 "Constrains effort on reasoning for reasoning models.": "Регулирует объём внутренних рассуждений модели (reasoning), для моделей которые поддерживают эту возможность.\nНа данный момент поддерживаются три значения: Подробные, Обычные, Поверхностные.\nПри менее подробном рассуждении ответ получается быстрее, а также экономятся токены, уходящие на рассуждения.",
2182 "openai_reasoning_effort_low": "Поверхностные",
2183 "openai_reasoning_effort_medium": "Обычные",
2184 "openai_reasoning_effort_high": "Подробные",
2185 "Persona Lore Alt+Click to open the lorebook": "Лорбук данной персоны\nAlt + ЛКМ чтобы открыть лорбук",
2186 "Persona Lorebook for": "Лорбук для персоны",
2187 "persona_world_template_txt": "Выбранная Информация о мире будет привязана к этой персоне. Информация будет добавляться в каждом промпте вместе с глобальным лорбуком и лорбуками персонажа и чата.",
2188 "Global list": "Глобальный список",
2189 "Preset-specific list": "Список для данного пресета",
2190 "Banned tokens/strings are being sent in the request.": "Запрещённые токены и строки отсылаются в запросе.",
2191 "Banned tokens/strings are NOT being sent in the request.": "Запрещённые токены и строки НЕ отсылаются в запросе.",
2192 "Add a reasoning block": "Добавить блок рассуждений",
2193 "Create a copy of this message?": "Продублировать это сообщение?",
2194 "Max Recursion Steps": "Макс. глубина рекурсии",
2195 "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc": "0 = неограничено, 1 = сканировать единожды, 2 = сканировать единожды и сделать один повторный проход, и т.д.\n(неактивно при указанном мин. числе активаций)",
2196 "(disabled when max recursion steps are used)": "(неактивно при указанной макс. глубине рекурсии)",
2197 "Enter a valid API URL": "Введите корректный адрес API",
2198 "No Ollama model selected.": "Не выбрана модель Ollama",
2199 "Background Fitting": "Способ подгонки фона под разрешение",
2200 "Chat Lore Alt+Click to open the lorebook": "Лорбук данного чата\nAlt + ЛКМ чтобы открыть лорбук",
2201 "Token Counter": "Подсчитать токены",
2202 "Type / paste in the box below to see the number of tokens in the text.": "Введите или вставьте текст в окошко ниже, чтобы подсчитать количество токенов в нём.",
2203 "Selected tokenizer:": "Выбранный токенайзер:",
2204 "Input:": "Входные данные:",
2205 "Tokenized text:": "Токенизированный текст:",
2206 "Token IDs:": "Идентификаторы токенов:",
2207 "Tokens:": "Токенов:"
2148}2208}
public/locales/uk-ua.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "Перевага запиту персонажа",633 "Prefer Character Card Prompt": "Перевага запиту персонажа",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Якщо відмічено і картка персонажа містить заміну джейлбрейку (Інструкцію), використовуйте її замість цього",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Якщо відмічено і картка персонажа містить заміну джейлбрейку (Інструкцію), використовуйте її замість цього",
635 "Prefer Character Card Jailbreak": "Перевага джейлбрейку персонажа",635 "Prefer Character Card Jailbreak": "Перевага джейлбрейку персонажа",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Уникайте обрізання та зміни розміру імпортованих зображень символів. Коли вимкнено, обрізати/змінити розмір до 512x768.",636 "never_resize_avatars_tooltip": "Уникайте обрізання та зміни розміру імпортованих зображень символів. Коли вимкнено, обрізати/змінити розмір до 512x768.",
637 "Never resize avatars": "Ніколи не змінювати розмір аватарів",637 "Never resize avatars": "Ніколи не змінювати розмір аватарів",
638 "Show actual file names on the disk, in the characters list display only": "Показувати фактичні назви файлів на диску, тільки у відображенні списку персонажів",638 "Show actual file names on the disk, in the characters list display only": "Показувати фактичні назви файлів на диску, тільки у відображенні списку персонажів",
639 "Show avatar filenames": "Показувати імена файлів аватарів",639 "Show avatar filenames": "Показувати імена файлів аватарів",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "Автоматичний змах",709 "Auto-swipe": "Автоматичний змах",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Вмикає функцію автоматичного змаху. Налаштування в цьому розділі діють лише тоді, коли увімкнено автоматичний змах",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Вмикає функцію автоматичного змаху. Налаштування в цьому розділі діють лише тоді, коли увімкнено автоматичний змах",
711 "Minimum generated message length": "Мінімальна довжина згенерованого повідомлення",711 "Minimum generated message length": "Мінімальна довжина згенерованого повідомлення",
712 "If the generated message is shorter than this, trigger an auto-swipe": "Якщо згенероване повідомлення коротше за це, викликайте автоматичний змаху",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Якщо згенероване повідомлення коротше за це, викликайте автоматичний змаху",
713 "Blacklisted words": "Список заборонених слів",713 "Blacklisted words": "Список заборонених слів",
714 "words you dont want generated separated by comma ','": "слова, які ви не хочете генерувати, розділені комою ','",714 "words you dont want generated separated by comma ','": "слова, які ви не хочете генерувати, розділені комою ','",
715 "Blacklisted word count to swipe": "Кількість заборонених слів для змаху",715 "Blacklisted word count to swipe": "Кількість заборонених слів для змаху",
public/locales/vi-vn.json+2 -2
@@ -633,7 +633,7 @@
633 "Prefer Character Card Prompt": "Ưu tiên Gợi ý từ Card",633 "Prefer Character Card Prompt": "Ưu tiên Gợi ý từ Card",
634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Nếu được kiểm tra và thẻ nhân vật chứa một lệnh phá vỡ giam giữ (Hướng dẫn Lịch sử Bài viết), hãy sử dụng thay vào đó",634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Nếu được kiểm tra và thẻ nhân vật chứa một lệnh phá vỡ giam giữ (Hướng dẫn Lịch sử Bài viết), hãy sử dụng thay vào đó",
635 "Prefer Character Card Jailbreak": "Ưu tiên Jailbreak từ Card",635 "Prefer Character Card Jailbreak": "Ưu tiên Jailbreak từ Card",
636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "Tránh cắt xén và thay đổi kích thước hình ảnh ký tự đã nhập. Khi tắt, hãy cắt/thay đổi kích thước thành 512x768.",636 "never_resize_avatars_tooltip": "Tránh cắt xén và thay đổi kích thước hình ảnh ký tự đã nhập. Khi tắt, hãy cắt/thay đổi kích thước thành 512x768.",
637 "Never resize avatars": "Không bao giờ thay đổi kích thước hình đại diện",637 "Never resize avatars": "Không bao giờ thay đổi kích thước hình đại diện",
638 "Show actual file names on the disk, in the characters list display only": "Hiển thị tên tệp thực tế trên đĩa, chỉ trong danh sách nhân vật",638 "Show actual file names on the disk, in the characters list display only": "Hiển thị tên tệp thực tế trên đĩa, chỉ trong danh sách nhân vật",
639 "Show avatar filenames": "Hiển thị tên tệp hình đại diện",639 "Show avatar filenames": "Hiển thị tên tệp hình đại diện",
@@ -709,7 +709,7 @@
709 "Auto-swipe": "Tự động vuốt",709 "Auto-swipe": "Tự động vuốt",
710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Bật chức năng tự động vuốt. Các cài đặt trong phần này chỉ có tác dụng khi tự động vuốt được bật",710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Bật chức năng tự động vuốt. Các cài đặt trong phần này chỉ có tác dụng khi tự động vuốt được bật",
711 "Minimum generated message length": "Độ dài tối thiểu của tin nhắn được tạo",711 "Minimum generated message length": "Độ dài tối thiểu của tin nhắn được tạo",
712 "If the generated message is shorter than this, trigger an auto-swipe": "Nếu tin nhắn được tạo ra ngắn hơn điều này, kích hoạt tự động vuốt",712 "If the generated message is shorter than these many characters, trigger an auto-swipe": "Nếu tin nhắn được tạo ra ngắn hơn điều này, kích hoạt tự động vuốt",
713 "Blacklisted words": "Từ trong danh sách đen",713 "Blacklisted words": "Từ trong danh sách đen",
714 "words you dont want generated separated by comma ','": "các từ bạn không muốn được tạo ra được phân tách bằng dấu phẩy ','",714 "words you dont want generated separated by comma ','": "các từ bạn không muốn được tạo ra được phân tách bằng dấu phẩy ','",
715 "Blacklisted word count to swipe": "Số từ trong danh sách đen để vuốt",715 "Blacklisted word count to swipe": "Số từ trong danh sách đen để vuốt",
public/locales/zh-cn.json+3 -4
@@ -584,7 +584,7 @@
584 "(0 = unlimited, use budget)": "(“0”为无限制,使用预算)",584 "(0 = unlimited, use budget)": "(“0”为无限制,使用预算)",
585 "Cap the number of entry activation recursions": "限制条目激活递归的次数",585 "Cap the number of entry activation recursions": "限制条目激活递归的次数",
586 "Max Recursion Steps": "最大递归深度",586 "Max Recursion Steps": "最大递归深度",
587 "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc\\n(disabled when min activations are used)": "“0”为无限制,“1”为扫描一次且不递归,“2”为扫描一次且递归一次,依此类推\n(当使用最小激活次数时,此功能被禁用)",587 "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc": "“0”为无限制,“1”为扫描一次且不递归,“2”为扫描一次且递归一次,依此类推\n(当使用最小激活次数时,此功能被禁用)",
588 "Insertion Strategy": "插入策略",588 "Insertion Strategy": "插入策略",
589 "Sorted Evenly": "均匀排序",589 "Sorted Evenly": "均匀排序",
590 "Character Lore First": "角色世界书优先",590 "Character Lore First": "角色世界书优先",
@@ -722,7 +722,7 @@
722 "Prefer Character Card Prompt": "角色卡提示词优先",722 "Prefer Character Card Prompt": "角色卡提示词优先",
723 "If checked and the character card contains a Post-History Instructions override, use that instead": "开启后,如果角色卡包含后历史指令覆盖,则使用它。",723 "If checked and the character card contains a Post-History Instructions override, use that instead": "开启后,如果角色卡包含后历史指令覆盖,则使用它。",
724 "Prefer Character Card Instructions": "首选角色卡说明",724 "Prefer Character Card Instructions": "首选角色卡说明",
725 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "避免裁剪和调整导入的角色图像的大小。关闭时,裁剪/调整大小为 512x768。",725 "never_resize_avatars_tooltip": "避免裁剪和调整导入的角色图像的大小。关闭时,裁剪/调整大小为 512x768。",
726 "Never resize avatars": "永不调整头像大小",726 "Never resize avatars": "永不调整头像大小",
727 "Show actual file names on the disk, in the characters list display only": "在角色列表显示中,显示磁盘上实际的文件名。",727 "Show actual file names on the disk, in the characters list display only": "在角色列表显示中,显示磁盘上实际的文件名。",
728 "Show avatar filenames": "显示头像文件名",728 "Show avatar filenames": "显示头像文件名",
@@ -804,7 +804,7 @@
804 "Auto-swipe": "自动滑动",804 "Auto-swipe": "自动滑动",
805 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "启用自动滑动功能。仅当启用自动滑动时,本节中的设置才会生效",805 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "启用自动滑动功能。仅当启用自动滑动时,本节中的设置才会生效",
806 "Minimum generated message length": "生成的消息的最小长度",806 "Minimum generated message length": "生成的消息的最小长度",
807 "If the generated message is shorter than this, trigger an auto-swipe": "如果生成的消息短于此长度,则触发自动滑动",807 "If the generated message is shorter than these many characters, trigger an auto-swipe": "如果生成的消息短于此长度,则触发自动滑动",
808 "Blacklisted words": "屏蔽词",808 "Blacklisted words": "屏蔽词",
809 "words you dont want generated separated by comma ','": "不想生成的词语,用半角逗号“,”分隔",809 "words you dont want generated separated by comma ','": "不想生成的词语,用半角逗号“,”分隔",
810 "Blacklisted word count to swipe": "触发滑动的黑名单词语数量",810 "Blacklisted word count to swipe": "触发滑动的黑名单词语数量",
@@ -1349,7 +1349,6 @@
1349 "Character Expressions": "角色表情",1349 "Character Expressions": "角色表情",
1350 "Translate text to English before classification": "分类之前将文本翻译成英文",1350 "Translate text to English before classification": "分类之前将文本翻译成英文",
1351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",1351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",
1352 "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)",
1353 "Classifier API": "分类器 API",1352 "Classifier API": "分类器 API",
1354 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",1353 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
1355 "Main API": "主要 API",1354 "Main API": "主要 API",
public/locales/zh-tw.json+198 -26
@@ -4,9 +4,9 @@
4 "Duplicate": "複製",4 "Duplicate": "複製",
5 "Persona": "使用者角色",5 "Persona": "使用者角色",
6 "Delete": "刪除",6 "Delete": "刪除",
7 "AI Response Configuration": "設定 AI 回應",7 "AI Response Configuration": "AI 回應設定",
8 "AI Configuration panel will stay open": "上鎖 = AI 設定面板將保持開啟",8 "AI Configuration panel will stay open": "上鎖 = AI 設定面板將保持開啟",
9 "clickslidertips": "點選滑桿數字可手動輸入。",9 "clickslidertips": "點擊滑桿旁的數字以手動輸入。",
10 "MAD LAB MODE ON": "瘋狂實驗室模式",10 "MAD LAB MODE ON": "瘋狂實驗室模式",
11 "Documentation on sampling parameters": "取樣參數的說明文件。",11 "Documentation on sampling parameters": "取樣參數的說明文件。",
12 "kobldpresets": "Kobold 預設設定檔",12 "kobldpresets": "Kobold 預設設定檔",
@@ -35,7 +35,7 @@
35 "Only enable this if your model supports context sizes greater than 8192 tokens": "僅在您的模型支援超過 8192 個符元的上下文長度時啟用此功能",35 "Only enable this if your model supports context sizes greater than 8192 tokens": "僅在您的模型支援超過 8192 個符元的上下文長度時啟用此功能",
36 "Max prompt cost:": "最大提示詞費用:",36 "Max prompt cost:": "最大提示詞費用:",
37 "Display the response bit by bit as it is generated.": "逐字顯示生成中的回應內容。",37 "Display the response bit by bit as it is generated.": "逐字顯示生成中的回應內容。",
38 "When this is off, responses will be displayed all at once when they are complete.": "關閉時,回應將在生成完成後一次性顯示。",38 "When this is off, responses will be displayed all at once when they are complete.": "關閉時,回應將在生成完成後一次全部顯示。",
39 "Temperature": "溫度",39 "Temperature": "溫度",
40 "rep.pen": "重複懲罰",40 "rep.pen": "重複懲罰",
41 "Rep. Pen. Range.": "重複懲罰範圍",41 "Rep. Pen. Range.": "重複懲罰範圍",
@@ -51,7 +51,7 @@
51 "Aggressive": "積極",51 "Aggressive": "積極",
52 "Very aggressive": "非常積極",52 "Very aggressive": "非常積極",
53 "Unlocked Context Size": "解鎖上下文長度",53 "Unlocked Context Size": "解鎖上下文長度",
54 "Unrestricted maximum value for the context slider": "不限制上下文滑桿最大值",54 "Unrestricted maximum value for the context slider": "不限制上下文長度的最大值",
55 "Context Size (tokens)": "上下文長度(符元數)",55 "Context Size (tokens)": "上下文長度(符元數)",
56 "Max Response Length (tokens)": "最大回應長度(符元數)",56 "Max Response Length (tokens)": "最大回應長度(符元數)",
57 "Multiple swipes per generation": "每次生成多次滑動",57 "Multiple swipes per generation": "每次生成多次滑動",
@@ -71,7 +71,7 @@
71 "Utility Prompts": "實用提示詞",71 "Utility Prompts": "實用提示詞",
72 "Impersonation prompt": "AI 扮演提示詞",72 "Impersonation prompt": "AI 扮演提示詞",
73 "Restore default prompt": "還原預設提示詞",73 "Restore default prompt": "還原預設提示詞",
74 "Prompt that is used for Impersonation function": "用於 AI 模仿功能的提示詞",74 "Prompt that is used for Impersonation function": "用於「AI 扮演使用者」功能的提示詞",
75 "World Info Format Template": "世界資訊格式",75 "World Info Format Template": "世界資訊格式",
76 "Restore default format": "還原預設格式",76 "Restore default format": "還原預設格式",
77 "Wraps activated World Info entries before inserting into the prompt.": "在插入提示詞前包裝已啟用的世界資訊條目。",77 "Wraps activated World Info entries before inserting into the prompt.": "在插入提示詞前包裝已啟用的世界資訊條目。",
@@ -79,7 +79,7 @@
79 "scenario_format_template_part_2": "來標示要插入內容的位置。",79 "scenario_format_template_part_2": "來標示要插入內容的位置。",
80 "Scenario Format Template": "場景格式",80 "Scenario Format Template": "場景格式",
81 "Personality Format Template": "個性格式",81 "Personality Format Template": "個性格式",
82 "Group Nudge Prompt Template": "群組推動提示詞範本",82 "Group Nudge Prompt Template": "群組聊天格式微調",
83 "Sent at the end of the group chat history to force reply from a specific character.": "在群組聊天歷史結束時發送以強制特定角色回覆",83 "Sent at the end of the group chat history to force reply from a specific character.": "在群組聊天歷史結束時發送以強制特定角色回覆",
84 "New Chat": "新聊天",84 "New Chat": "新聊天",
85 "Restore new chat prompt": "還原新聊天的提示詞",85 "Restore new chat prompt": "還原新聊天的提示詞",
@@ -89,17 +89,17 @@
89 "Set at the beginning of the chat history to indicate that a new group chat is about to start.": "設定在聊天歷史的開頭以表明即將開始新的群組聊天",89 "Set at the beginning of the chat history to indicate that a new group chat is about to start.": "設定在聊天歷史的開頭以表明即將開始新的群組聊天",
90 "New Example Chat": "新範例聊天",90 "New Example Chat": "新範例聊天",
91 "Set at the beginning of Dialogue examples to indicate that a new example chat is about to start.": "設定在對話範例的開頭以表明即將開始新的範例聊天",91 "Set at the beginning of Dialogue examples to indicate that a new example chat is about to start.": "設定在對話範例的開頭以表明即將開始新的範例聊天",
92 "Continue nudge": "繼續輔助提示詞",92 "Continue nudge": "繼續輔助微調",
93 "Set at the end of the chat history when the continue button is pressed.": "按下繼續按鈕時設定在聊天歷史的末尾",93 "Set at the end of the chat history when the continue button is pressed.": "按下「繼續」按鈕時,插入於聊天歷史的末尾",
94 "Replace empty message": "取代空白訊息",94 "Replace empty message": "取代空白訊息",
95 "Send this text instead of nothing when the text box is empty.": "當文字方塊為空時,發送此字串而不是空白。",95 "Send this text instead of nothing when the text box is empty.": "當文本框為空時,發送此文本以取代空白。",
96 "Seed": "種子",96 "Seed": "種子",
97 "Set to get deterministic results. Use -1 for random seed.": "設定以獲取確定性結果。使用 -1 作為隨機種子",97 "Set to get deterministic results. Use -1 for random seed.": "設定數值以取得可重現的結果。使用 -1 作為隨機種子",
98 "Temperature controls the randomness in token selection": "溫度控制符元選擇中的隨機性",98 "Temperature controls the randomness in token selection": "溫度(Temperature)控制符元選擇的隨機性。\n- 低溫(<1.0):產生更可預測且具邏輯性的文本,優先選擇機率較高的符元。\n- 高溫(>1.0):提升創造性與輸出的多樣性,更常選擇機率較低的符元。\n將值設為 1.0 可使用原始機率。",
99 "Top_K_desc": "Top K 設定可以選擇的最高符元數量。\n例如,Top K 為 20,這意味著只保留排名前 20 的符元(無論它們的機率是多樣還是有限的)。\n設定為 0 以停用。",99 "Top_K_desc": "Top K 設定可以選擇的最高符元數量。\n例如,Top K 為 20,這意味著只保留排名前 20 的符元(無論它們的機率是多樣還是有限的)。\n設定為 0 以停用。",
100 "Top_P_desc": "Top P(又名核心取樣)會將所有頂級符元加總,直到達到目標百分比。\n例如,如果前兩個符元都是 25%,而 Top P 設為 0.5,那麼只有前兩個符元會被考慮。\n設定為 1.0 以停用。",100 "Top_P_desc": "Top P(又名核心取樣)會將所有頂級符元加總,直到達到目標百分比。\n例如,如果前兩個符元都是 25%,而 Top P 設為 0.5,那麼只有前兩個符元會被考慮。\n設定為 1.0 以停用。",
101 "Typical P": "Typical P",101 "Typical P": "Typical P",
102 "Typical_P_desc": "Typical P 取樣根據符元偏離集合平均熵的程度進行優先排序。\n它會保留累積機率接近預設閾值(例如0.5)的符元,強調那些具有平均信息量的符元。\n設定為 1.0 以停用。",102 "Typical_P_desc": "Typical P 取樣根據符元偏離集合平均熵的程度進行優先排序。\n它會保留累積機率接近預設閾值(例如0.5)的符元,強調那些具有平均信息量的符元。\n設定為 1.0 以停用。",
103 "Min_P_desc": "Min P 設定基本最小機率。\n這個值會根據最高符元的機率進行調整。例如,如果最高符元機率為 80%,而 Min P 設為 0.1 ,那麼只有機率高於 8% 的符元會被考慮。\n設定為 0 以停用。",103 "Min_P_desc": "Min P 設定基本最小機率。\n這個值會根據最高符元的機率進行調整。例如,如果最高符元機率為 80%,而 Min P 設為 0.1 ,那麼只有機率高於 8% 的符元會被考慮。\n設定為 0 以停用。",
104 "Top_A_desc": "Top A 根據最高符元機率的平方設定符元選擇的門檻。\n例如,如果 Top A 值為 0.2,而最高符元機率為 50%,那麼低於 5%(0.2 * 0.5^2) 的符元機率就會被排除。\n設定為 0 以停用。",104 "Top_A_desc": "Top A 根據最高符元機率的平方設定符元選擇的門檻。\n例如,如果 Top A 值為 0.2,而最高符元機率為 50%,那麼低於 5%(0.2 * 0.5^2) 的符元機率就會被排除。\n設定為 0 以停用。",
105 "Tail_Free_Sampling_desc": "無尾取樣(Tail-Free Sampling, TFS)會透過分析符元機率的變化率(使用導數)來尋找分佈中的低機率符元尾部。\n它會根據標準化的二階導數,保留直到某個閾值(例如0.3)的符元。\n數值越接近 0 ,表示會棄去越多符元。設定為 1.0 以停用。",105 "Tail_Free_Sampling_desc": "無尾取樣(Tail-Free Sampling, TFS)會透過分析符元機率的變化率(使用導數)來尋找分佈中的低機率符元尾部。\n它會根據標準化的二階導數,保留直到某個閾值(例如0.3)的符元。\n數值越接近 0 ,表示會棄去越多符元。設定為 1.0 以停用。",
@@ -126,10 +126,10 @@
126 "Logit Bias": "Logit 偏差",126 "Logit Bias": "Logit 偏差",
127 "Add": "新增",127 "Add": "新增",
128 "Helps to ban or reenforce the usage of certain words": "有助於禁止或強化某些符元的使用",128 "Helps to ban or reenforce the usage of certain words": "有助於禁止或強化某些符元的使用",
129 "CFG Scale": "CFG 比例",129 "CFG Scale": "CFG 縮放比例",
130 "Negative Prompt": "負面提示詞",130 "Negative Prompt": "負面提示詞",
131 "Add text here that would make the AI generate things you don't want in your outputs.": "在這裡新增文字,使 AI 生成您不希望在輸出中出現的內容。",131 "Add text here that would make the AI generate things you don't want in your outputs.": "在此新增文字,以防止 AI 在輸出中生成您不希望出現的內容。",
132 "Used if CFG Scale is unset globally, per chat or character": "如果CFG Scale未在全域、每個聊天或角色中設定,則使用",132 "Used if CFG Scale is unset globally, per chat or character": "若 CFG 縮放比例未被全域設定,它將作用於所有聊天或角色",
133 "Mirostat Tau": "Tau",133 "Mirostat Tau": "Tau",
134 "Mirostat LR": "Mirostat 學習率",134 "Mirostat LR": "Mirostat 學習率",
135 "Min Length": "最小長度",135 "Min Length": "最小長度",
@@ -399,7 +399,7 @@
399 "Applies additional processing to the prompt before sending it to the API.": "這個選項會在將提示詞送往 API 之前,對它進行額外的處理。",399 "Applies additional processing to the prompt before sending it to the API.": "這個選項會在將提示詞送往 API 之前,對它進行額外的處理。",
400 "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "透過發送簡短的測試訊息來驗證您的 API 連線。請注意,您將因此獲得榮譽!",400 "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "透過發送簡短的測試訊息來驗證您的 API 連線。請注意,您將因此獲得榮譽!",
401 "Test Message": "測試訊息",401 "Test Message": "測試訊息",
402 "Auto-connect to Last Server": "自動連線到上次伺服器",402 "Auto-connect to Last Server": "自動連接至上次使用的伺服器",
403 "Missing key": "❌ 鑰匙遺失",403 "Missing key": "❌ 鑰匙遺失",
404 "Key saved": "✔️ 金鑰已儲存",404 "Key saved": "✔️ 金鑰已儲存",
405 "View hidden API keys": "查看隱藏的 API 金鑰",405 "View hidden API keys": "查看隱藏的 API 金鑰",
@@ -634,7 +634,7 @@
634 "Prefer Character Card Prompt": "角色卡主要提示詞優先",634 "Prefer Character Card Prompt": "角色卡主要提示詞優先",
635 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "如果選中並且角色卡包含越獄覆寫(聊天歷史後指示),則使用該提示詞。",635 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "如果選中並且角色卡包含越獄覆寫(聊天歷史後指示),則使用該提示詞。",
636 "Prefer Character Card Jailbreak": "角色卡越獄優先",636 "Prefer Character Card Jailbreak": "角色卡越獄優先",
637 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "避免裁剪和調整匯入的角色圖像大小。未勾選時將會裁剪/調整大小到 512x768。",637 "never_resize_avatars_tooltip": "避免裁剪與調整導入的角色頭像大小。未啟用此選項時,圖片將被裁剪/調整為 512x768。此設定會關閉上傳頭像時的裁剪彈出視窗。",
638 "Never resize avatars": "永不調整頭像大小",638 "Never resize avatars": "永不調整頭像大小",
639 "Show actual file names on the disk, in the characters list display only": "僅在角色列表顯示實際檔案名稱。",639 "Show actual file names on the disk, in the characters list display only": "僅在角色列表顯示實際檔案名稱。",
640 "Show avatar filenames": "顯示頭像檔案名",640 "Show avatar filenames": "顯示頭像檔案名",
@@ -710,7 +710,7 @@
710 "Auto-swipe": "自動滑動",710 "Auto-swipe": "自動滑動",
711 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "啟用自動滑動功能。此部分的設定僅在啟用自動滑動時有效。",711 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "啟用自動滑動功能。此部分的設定僅在啟用自動滑動時有效。",
712 "Minimum generated message length": "生成訊息的最小長度",712 "Minimum generated message length": "生成訊息的最小長度",
713 "If the generated message is shorter than this, trigger an auto-swipe": "如果生成的訊息比這個短,將觸發自動滑動。",713 "If the generated message is shorter than these many characters, trigger an auto-swipe": "如果生成的訊息比這個短,將觸發自動滑動。",
714 "Blacklisted words": "黑名單詞語",714 "Blacklisted words": "黑名單詞語",
715 "words you dont want generated separated by comma ','": "您不想生成的文字,使用逗號分隔",715 "words you dont want generated separated by comma ','": "您不想生成的文字,使用逗號分隔",
716 "Blacklisted word count to swipe": "滑動的黑名單詞語數量",716 "Blacklisted word count to swipe": "滑動的黑名單詞語數量",
@@ -1008,7 +1008,7 @@
1008 "prompt_manager_edit": "編輯",1008 "prompt_manager_edit": "編輯",
1009 "prompt_manager_name": "名稱",1009 "prompt_manager_name": "名稱",
1010 "A name for this prompt.": "這個提示詞的名稱。",1010 "A name for this prompt.": "這個提示詞的名稱。",
1011 "To whom this message will be attributed.": "此訊息將隸屬於誰。",1011 "To whom this message will be attributed.": "此訊息所屬的角色。",
1012 "AI Assistant": "人工智慧助手",1012 "AI Assistant": "人工智慧助手",
1013 "prompt_manager_position": "位置",1013 "prompt_manager_position": "位置",
1014 "Injection position. Next to other prompts (relative) or in-chat (absolute).": "注入位置。與其他提示詞相鄰(相對位置)或在聊天中(絕對位置)。",1014 "Injection position. Next to other prompts (relative) or in-chat (absolute).": "注入位置。與其他提示詞相鄰(相對位置)或在聊天中(絕對位置)。",
@@ -1458,7 +1458,6 @@
1458 "Example: http://localhost:1234/v1": "例如:http://localhost:1234/v1",1458 "Example: http://localhost:1234/v1": "例如:http://localhost:1234/v1",
1459 "popup-button-crop": "裁剪",1459 "popup-button-crop": "裁剪",
1460 "(disabled when max recursion steps are used)": "(當最大遞歸步驟數使用時將停用)",1460 "(disabled when max recursion steps are used)": "(當最大遞歸步驟數使用時將停用)",
1461 "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc\n(disabled when min activations are used)": "0 = 無限制,1 = 掃描一次且不遞歸,2 = 掃描一次並遞歸一次,以此類推\n(使用最小啟動設定時將停用)",
1462 "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 抽樣的貪婪演算法,用於尋找最可能的單詞或標記序列。該方法會同時展開多個候選序列,並在每一步中保持固定數量的頂級序列(beam width)。",1461 "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 抽樣的貪婪演算法,用於尋找最可能的單詞或標記序列。該方法會同時展開多個候選序列,並在每一步中保持固定數量的頂級序列(beam width)。",
1463 "A multiplicative factor to expand the overall area that the nodes take up.": "節點佔用該擴充功能區域的倍數。",1462 "A multiplicative factor to expand the overall area that the nodes take up.": "節點佔用該擴充功能區域的倍數。",
1464 "Abort current image generation task": "終止目前的圖片生成任務",1463 "Abort current image generation task": "終止目前的圖片生成任務",
@@ -1653,7 +1652,6 @@
1653 "HuggingFace Token": "HuggingFace 符元",1652 "HuggingFace Token": "HuggingFace 符元",
1654 "Image Captioning": "圖片註解",1653 "Image Captioning": "圖片註解",
1655 "Generate Caption": "產生圖片註解",1654 "Generate Caption": "產生圖片註解",
1656 "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)",
1657 "Injection Position": "插入位置",1655 "Injection Position": "插入位置",
1658 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",1656 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
1659 "Injection Template": "插入範本",1657 "Injection Template": "插入範本",
@@ -1708,7 +1706,7 @@
1708 "Quick Impersonate button": "快速模擬按鈕",1706 "Quick Impersonate button": "快速模擬按鈕",
1709 "Recursion Level": "遞迴層級",1707 "Recursion Level": "遞迴層級",
1710 "Remove all image overrides": "移除所有圖片覆蓋",1708 "Remove all image overrides": "移除所有圖片覆蓋",
1711 "Restore default": "",1709 "Restore default": "恢復預設",
1712 "Retain#": "保留#",1710 "Retain#": "保留#",
1713 "Retrieve chunks": "檢索 Chunks",1711 "Retrieve chunks": "檢索 Chunks",
1714 "Sampler Order": "取樣順序",1712 "Sampler Order": "取樣順序",
@@ -1762,7 +1760,7 @@
1762 "Threshold": "閾值",1760 "Threshold": "閾值",
1763 "to install 3rd party extensions.": "用於安裝第三方擴充功能。",1761 "to install 3rd party extensions.": "用於安裝第三方擴充功能。",
1764 "Top": "頂部",1762 "Top": "頂部",
1765 "Translate text to English before classification": "在分類前將文本翻譯為英文。",1763 "Translate text to English before classification": "分類前,將訊息翻譯為英文",
1766 "Uncheck to hide the extensions messages in chat prompts.": "不勾選即可隱藏聊天提示詞中的擴充功能訊息。",1764 "Uncheck to hide the extensions messages in chat prompts.": "不勾選即可隱藏聊天提示詞中的擴充功能訊息。",
1767 "Unchecked: only entries with ❌ status can be activated.": "未勾選時:僅允許啟用狀態為 ❌ 的條目。",1765 "Unchecked: only entries with ❌ status can be activated.": "未勾選時:僅允許啟用狀態為 ❌ 的條目。",
1768 "Unified Sampling": "統一取樣(Unified Sampling)",1766 "Unified Sampling": "統一取樣(Unified Sampling)",
@@ -1806,7 +1804,7 @@
1806 "context_derived": "若可能,根據模型元數據推導。",1804 "context_derived": "若可能,根據模型元數據推導。",
1807 "instruct_derived": "若可能,根據模型元數據推導。",1805 "instruct_derived": "若可能,根據模型元數據推導。",
1808 "Inserted before the first User's message.": "插入於第一則使用者訊息之前。",1806 "Inserted before the first User's message.": "插入於第一則使用者訊息之前。",
1809 "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc\\n(disabled when min activations are used)": "0 = 無限制,1 = 掃描一次不遞歸,2 = 掃描一次後遞歸一次 ⋯以此類推\n(啟用最小啟動次數時無效)",1807 "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc": "0 = 無限制,1 = 掃描一次不遞歸,2 = 掃描一次後遞歸一次 ⋯以此類推\n(啟用最小啟動次數時無效)",
1810 "Quick 'Impersonate' button": "快速「AI 扮演使用者」按鈕",1808 "Quick 'Impersonate' button": "快速「AI 扮演使用者」按鈕",
1811 "Manual": "手動",1809 "Manual": "手動",
1812 "Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)": "此處填入的內容將取代該角色的默認聊天歷史後指示(Post-History Instructions)。\n(v2 格式:specpost_history_instructions)",1810 "Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)": "此處填入的內容將取代該角色的默認聊天歷史後指示(Post-History Instructions)。\n(v2 格式:specpost_history_instructions)",
@@ -1891,7 +1889,7 @@
1891 "Narrate user messages": "朗讀使用者訊息",1889 "Narrate user messages": "朗讀使用者訊息",
1892 "Auto Generation": "自動生成",1890 "Auto Generation": "自動生成",
1893 "Requires auto generation to be enabled.": "需要啟用自動生成功能。",1891 "Requires auto generation to be enabled.": "需要啟用自動生成功能。",
1894 "Narrate by paragraphs (when streaming)": "按段落朗讀(使用「串流」傳輸時)",1892 "Narrate by paragraphs (when streaming)": "按段落朗讀(使用「串流」時)",
1895 "Only narrate quotes": "僅朗讀「引號」中的文字",1893 "Only narrate quotes": "僅朗讀「引號」中的文字",
1896 "Ignore text, even quotes, inside asterisk": "忽略 *(星號)內的文字(包括「引號」)",1894 "Ignore text, even quotes, inside asterisk": "忽略 *(星號)內的文字(包括「引號」)",
1897 "Narrate only the translated text": "僅朗讀翻譯後的文本",1895 "Narrate only the translated text": "僅朗讀翻譯後的文本",
@@ -2452,5 +2450,179 @@
2452 "Modules provided by your Extras API:": "由您的 Extras API 提供的模組:",2450 "Modules provided by your Extras API:": "由您的 Extras API 提供的模組:",
2453 "Not connected to the API!": "未連線到 API!",2451 "Not connected to the API!": "未連線到 API!",
2454 "ext_type_system": "這是內建的擴充功能,無法刪除,且會跟隨系統更新。",2452 "ext_type_system": "這是內建的擴充功能,無法刪除,且會跟隨系統更新。",
2455 "Valid": "已驗證"2453 "Valid": "已驗證",
2454 "Request Model Reasoning": "請求模型推理",
2455 "Global list": "全域列表",
2456 "Preset-specific list": "特定預設列表",
2457 "Constrains effort on reasoning for reasoning models.": "限制推理模型的推理耗費。\n目前支援的值為低、中和高。\n降低推理耗費可加快回應速度,並減少推理所使用的符元數量。",
2458 "Reasoning Effort": "推理耗費",
2459 "openai_reasoning_effort_low": "低",
2460 "openai_reasoning_effort_medium": "中",
2461 "openai_reasoning_effort_high": "高",
2462 "Reasoning": "推理 Reasoning",
2463 "reasoning_auto_parse": "自動解析主要內容中推理區塊,需定義且不為空的前綴與後綴欄位。",
2464 "Auto-Parse": "自動解析",
2465 "reasoning_auto_expand": "自動展開推理區塊。",
2466 "Auto-Expand": "自動展開",
2467 "reasoning_show_hidden": "顯示隱藏推理功能模型的推理時間",
2468 "Show Hidden": "顯示隱藏內容",
2469 "reasoning_add_to_prompts": "將現有推理區塊添加至提示詞中。若需新增推理區塊,請使用訊息編輯選單。",
2470 "Add to Prompts": "添加至提示詞",
2471 "reasoning_max_additions": "從最後一則訊息起算,每則提示詞中可添加的最大推理區塊數量。",
2472 "Max": "最大值",
2473 "Reasoning Formatting": "推理格式",
2474 "reasoning_prefix": "插入於推理內容之前。",
2475 "Prefix": "前綴",
2476 "reasoning_suffix": "插入於推理內容之後。",
2477 "Suffix": "後綴",
2478 "reasoning_separator": "插入於推理內容與訊息內容之間。",
2479 "Separator": "分隔符",
2480 "Character details are hidden.": "角色詳情已隱藏。",
2481 "Add a reasoning block": "新增推理區塊",
2482 "Thought for some time": "思考了一段時間",
2483 "Confirmedit": "確認",
2484 "Remove reasoning": "移除推理",
2485 "Cancel edit": "取消編輯",
2486 "Copy reasoning": "複製推理",
2487 "Edit reasoning": "編輯推理",
2488 "extension_install_1": "若要從此頁面下載擴充功能,您需要安裝",
2489 "extension_install_2": "已安裝。",
2490 "extension_install_3": "點擊",
2491 "extension_install_4": "圖示以訪問擴充功能的儲存庫,查看使用技巧。",
2492 "Use the selected API from Chat Translation extension settings.": "使用擴充功能設定中,「聊天翻譯」所選的翻譯提供者(API)。",
2493 "A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.": "單個同名表情可以有多張角色立繪。每次使用該表情時,會隨機擇一顯示。",
2494 "Allow multiple sprites per expression": "允許單一表情使用多張立繪",
2495 "If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.": "若再次使用相同的表情,將重新隨機選擇。此功能僅適用於分配了多張立繪的表情。",
2496 "Re-roll if same expression is used again": "重複使用同名表情時,隨機選用其他立繪",
2497 "upload_expression_request": "請輸入角色立繪名稱(不含副檔名)。",
2498 "upload_expression_naming_1": "角色立繪名稱必須符合所選表情的命名規則:{{expression}}",
2499 "upload_expression_naming_2": "對於多個表情,名稱必須包含表情名稱和有效的後綴,允許的分隔符為「-」或「.」。",
2500 "upload_expression_replace": "點擊「取代」以取代現有表情:",
2501 "ext_regex_reasoning_desc": "推理區塊內容。當「僅格式化提示詞」已勾選時,這也會影響添加至提示詞的推理內容。",
2502 "Token Counter": "符元計數器",
2503 "Type / paste in the box below to see the number of tokens in the text.": "在下框中輸入或貼上文字以查看符元(Token)數量。",
2504 "Selected tokenizer:": "選擇的分詞器:",
2505 "Input:": "輸入:",
2506 "Tokens:": "符元數:",
2507 "Tokenized text:": "已符元化的文字:",
2508 "Token IDs:": "符元 ID:",
2509 "Narrate by paragraphs (when not streaming)": "按段落朗讀(不使用「串流」時)",
2510 " folder (typically in ": "資料夾(通常位於 ",
2511 "Copy to Clipboard": "複製到剪貼簿",
2512 "Reset to Defaults": "重設為預設值",
2513 "Toggles Guinevere features.": "切換 Guinevere 功能。",
2514 "Update customCSS": "更新 customCSS",
2515 "Apply Theme": "套用主題",
2516 "Enable Guinevere": "啟用 Guinevere",
2517 "Note: Themes can be made/applied by going to the ": "注意:主題可通過前往以下位置進行創建/應用",
2518 "Theme Name": "主題名稱",
2519 "An unknown error occurred while counting tokens. Further information may be available in console.": "計算符元時發生未知錯誤。更多資訊可能可在主控台(console)中查看。",
2520 "Qvink Memory": "Qvink Memory(進階聊天記憶)",
2521 "Toggle whether memory is enabled for this chat specifically (overrides all settings).": "切換是否為此聊天啟用記憶功能(將覆蓋所有設定)。",
2522 "Toggle Chat Memory": "切換聊天記憶",
2523 "Preview current memory state (the exact text that will be injected into your context).": "預覽目前記憶狀態(包含將嵌入上下文的具體內容)。",
2524 "Copy ALL memories to clipboard (all memories in the entire chat, not just those injected).": "將所有記憶複製到剪貼簿(包含整個聊天的所有記憶,而非僅限於注入的部分)。",
2525 "Just refreshes which memories are included and re-renders the memories under each message, doesn't change summaries. This is done automatically all the time, the button is here just in case.": "不影響摘要,僅更新已包含的聊天記憶,並重新顯示在每則訊息下方。此過程通常會自動執行,按鈕只是備用選項。",
2526 "Active Settings Profile ": "目前設定檔",
2527 "Create, edit, and save configuration profiles for this extension.": "建立、編輯及儲存此擴充功能的設定檔。",
2528 "The currently selected profile": "目前選取的設定檔",
2529 "Save current profile": "儲存此設定檔",
2530 "Rename current profile": "重新命名此設定檔",
2531 "Create new profile": "建立新設定檔",
2532 "Restore current profile": "還原此設定檔",
2533 "Delete current profile": "刪除此設定檔",
2534 "Set as default profile for current character": "設為目前角色的預設設定檔",
2535 "Summarization": "摘要",
2536 "Customize the prompt used to summarize a given message": "自訂用於摘要指定訊息的提示詞",
2537 "Edit the summary prompt": "編輯摘要提示",
2538 "Preview the filled-in summary prompt, using the last message as an example.": "以最後一則訊息為例,預覽填充完成的摘要提示",
2539 "Mass re-summarization. Brings up dialog to choose subsets of messages to summarize or re-summarize.": "批量重新摘要:開啟對話框以選擇訊息子集進行摘要或重新摘要。",
2540 "Stop all summarization immediately.": "立即停止所有摘要。",
2541 "New messages will be automatically summarized if they will be included in short-term memory.": "如果新訊息將被納入短期記憶,將自動進行摘要。",
2542 "Auto Summarize": "自動摘要",
2543 "Auto-summarization will be triggered before a new message is sent instead of after.": "自動摘要將在發送新訊息之前觸發。",
2544 "Auto Summarize Before Generation": "在生成內容前自動摘要",
2545 "Show the progress bar when auto-summarizing more than 1 message.": "在自動摘要多於 1 則訊息時顯示進度條。",
2546 "Auto Summarize Progress Bar": "自動摘要進度條",
2547 "Number of messages to delay summarization (0 = summarize up to the most recent message, 1 = lag behind by one message, etc.)": "延遲摘要的訊息數量(0 = 摘要至最新訊息,1 = 延遲摘要 1 則訊息,以此類推)。",
2548 "Auto Summarize Message Lag": "自動摘要訊息延遲",
2549 "Wait until this many messages before auto-summarizing them all in sequence (1 = summarize every message immediately, 2 = summarize when you have two ready, etc). Still summarizes one at a time.": "在訊息數量達到此設定值後,依序自動摘要(1 = 即時摘要每則訊息,2 = 等待 2 則訊息後再摘要,以此類推)。摘要將逐條執行。",
2550 "Auto Summarize Batch Size": "自動摘要批次大小",
2551 "The maximum number of messages back that auto-summarization will apply (-1 to disable).": "自動摘要可回溯的訊息最大數量(-1 表示禁用此功能)。",
2552 "Auto Summarize Message Limit": "自動摘要訊息上限",
2553 "Time in seconds to wait between summarizations. May be needed if you are using a external API with a rate limit.": "每次摘要的間隔時間(秒)。此設定適用於使用具有請求速率限制的外部 API。",
2554 "Summarization Time Delay": "摘要時間延遲",
2555 "The maximum token length a summary is allowed to be before cutting it off. Use the {{words}} macro in the summarization prompt to get this value.": "摘要在被截斷前允許的最大符元(token)長度。可在摘要提示中使用 {{words}} 巨集以取得此數值。",
2556 "Summary Max Token Length": "摘要允許的最大符元長度",
2557 "Editing a message will automatically trigger a re-summarization if it has already been summarized.": "編輯訊息時,若該訊息已被摘要,將自動觸發重新摘要。",
2558 "Re-summarize on Edit": "編輯後重新摘要",
2559 "Swiping a message will automatically trigger a re-summarization if it has already been summarized.": "滑動訊息後若已進行摘要,將自動觸發重新摘要。",
2560 "Re-summarize on Swipe": "滑動後重新摘要",
2561 "Block chat input while summarizing.": "在摘要進行時暫時禁用聊天訊息輸入。",
2562 "Block Chat": "訊息輸入鎖定",
2563 "Whether to use messages and/or summaries as context for summarization. You must use {{history}} in the summary prompt.": "決定是否在摘要中使用訊息及/或過往摘要作為背景資訊。需於摘要提示詞中,使用 {{history}}。",
2564 "Message History": "訊息歷史",
2565 "Messages": "僅訊息",
2566 "Summaries": "僅摘要",
2567 "Both": "訊息與摘要",
2568 "Preview what the message history will look like": "預覽訊息歷史的顯示效果",
2569 "How many previous messages to include in the summarization prompt as context.": "摘要提示中要包含多少先前訊息作為上下文。",
2570 "Number of Previous Messages": "先前訊息數量",
2571 "When including previous messages, also include user messages.": "包含先前訊息時,也包含使用者訊息。",
2572 "Include Previous User Messages": "包含先前使用者訊息",
2573 "The message to summarize will be inside the system instruct template itself. In unchecked (default), the message will instead be added separately after the prompt. Some models benefit from this, but it is not recommended.": "系統指令模板內將直接包含需要摘要的訊息。若未啟用此選項(預設設定),訊息會在提示後分開添加。儘管某些模型可能更適合此設定,但一般不建議使用。",
2574 "Nest Message in Summary Prompt": "在摘要提示中內嵌訊息",
2575 "WARNING: doesn't work great. Attempts to preserve context-shifting by including all the content that is sent in regular prompts (world info, description, personas, example messages, message history, etc). If your regular prompts are static, this can allow Context Shifting to work between summarizations, but it decreases the accuracy of summarization due to all the extra stuff in the prompt. It also can't be previewed as this injection is handled by ST, not the extension.": "警告:效果不佳。此功能嘗試透過在摘要時包含所有常規提示內容(如世界資訊、描述、角色設定、示範訊息、聊天歷史等)來保留上下文轉換。若您的常規提示為靜態內容,則可在摘要之間維持上下文轉換,但由於提示中包含大量額外資訊,將降低摘要的準確性。此外,此內容注入由 ST 處理,而非此擴充功能,因此無法進行預覽。",
2576 "Include All Context Content": "包含所有上下文內容",
2577 "Short-term Memory Injection": "短期記憶注入",
2578 "Determines which messages are included in the short-term memory injection and where. If you change this and include messages that weren't summarized previously, you can either manually trigger a re-summarization or just wait until automatic summarization triggers.": "設定短期記憶注入中所包含的訊息及其插入位置。若更改此設定並包含先前未摘要的訊息,您可手動觸發重新摘要,或等待自動摘要啟動。",
2579 "Edit the short-term memory prompt": "編輯短期記憶提示",
2580 "Include User Messages": "包含使用者訊息",
2581 "Include System Messages": "包含系統訊息",
2582 "Include Thought Message": "包含思考訊息",
2583 "Message Length Threshold": "訊息長度閾值",
2584 "The minimum token length a message has to be in order to get summarized.": "可被摘要的訊息最小符元長度。",
2585 "The max percent of the context that short-term memory can take up.": "短期記憶可佔用上下文的最大百分比。",
2586 "Short-Term Context %": "短期記憶上下文%",
2587 "Include short-term memory in the World Info Scan": "在世界資訊掃描中包含短期記憶",
2588 "Do not inject": "不注入",
2589 "Before main prompt": "主提示之前",
2590 "After main prompt": "主提示之後",
2591 "In chat at depth": "在對話中位於深度",
2592 "Long-Term Memory Injection": "長期記憶注入",
2593 "Determines where long-term messages are injected.": "決定長期訊息注入的位置。",
2594 "Edit the long-term memory prompt": "編輯長期記憶提示",
2595 "The max percent of the context that long-term memory can take up.": "長期記憶可佔用上下文的最大百分比。",
2596 "Long-Term Context %": "長期記憶上下文%",
2597 "Include long-term memory in the World Info Scan": "在世界資訊掃描中包含長期記憶",
2598 "Misc.": "其他",
2599 "Fill your console with debug messages": "將偵錯訊息填入主控台",
2600 "Debug Mode": "偵錯模式",
2601 "Display summarizations below each message": "在每則訊息下顯示摘要",
2602 "Display Memories": "顯示記憶",
2603 "Enable Memory in New Chats": "在新對話中啟用記憶",
2604 "Limit Message History": "限制訊息歷史",
2605 "Revert Settings": "還原設定",
2606 "Auto-summarize user messages and include summaries in memory.": "自動摘要使用者訊息,並將該摘要納入記憶。",
2607 "Auto-summarize system messages and include summaries in memory.": "自動摘要系統訊息,並將該摘要納入記憶。",
2608 "Auto-summarize thought messages and include summaries in memory (from the Stepped Thinking extension).": "自動摘要思考訊息並將摘要納入記憶(來自 Stepped Thinking 擴充功能)。",
2609 "Revert all settings to default (not the default profile, just the default that comes with the extension). Your other profiles won't be affected.": "將所有設定恢復為預設值(並非恢復至「預設設定檔」,而是擴充功能隨附的原始預設值)。其他設定檔將不受影響。",
2610 "Limit the number of messages to send in regular prompts to this number (-1 for no limit). Message memories will still be sent.": "限制常規提示中傳送的訊息數量至此數值(-1 表示無限制)。訊息記憶仍將一併傳送。",
2611 "Whether memory is enabled by default for new chats.": "是否在新對話中預設啟用記憶。",
2612 "Summarize Chat": "摘要對話",
2613 "Choose settings for the chat summarization. All message inclusion/exclusion settings from the main config profile are used, in addition to the following options.": "選擇聊天摘要的設定。摘要時將使用主要設定檔中的所有訊息包含/排除規則,並可額外設定以下選項。",
2614 "Currently preparing to summarize:": "目前正在準備摘要:",
2615 "Summarize messages with no existing summary": "摘要尚無摘要的訊息",
2616 "Re-summarize messages with existing short-term memories": "重新摘要具有現有短期記憶的訊息",
2617 "Re-summarize messages with existing long-term memories": "重新摘要具有現有長期記憶的訊息",
2618 "Re-summarize messages with existing memories, but which are currently excluded from short-term and long-term memory": "重新摘要具有現有記憶,但目前被排除在短期和長期記憶之外的訊息",
2619 "Re-summarize messages with existing memories that have been manually edited.": "重新摘要已手動編輯的訊息記憶",
2620 "Type the folder name of the theme you want to apply.": "輸入您想套用的主題資料夾名稱。",
2621 "Place your theme data in a folder.": "請將主題資料存於該資料夾內。",
2622 "Unsure where to start? Type ": "不確定如何開始?輸入:",
2623 " to apply the default Google Messages theme or click ": " 即可使用預設主題 Google Messages,或點擊",
2624 "here": "這裡",
2625 " to learn how to create your own theme.": " 以學習如何創建個人化主題。",
2626 "Guinevere (UI Theme Extension)": "Guinevere(進階自定義 UI 主題)",
2627 "and Guinaifen.": "和 Guinaifen(桂乃芬)呈獻。"
2456}2628}
public/script.js+48 -6
@@ -366,6 +366,10 @@ DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
366 return;366 return;
367 }367 }
368368
369 if (!(node instanceof Element)) {
370 return;
371 }
372
369 let mediaBlocked = false;373 let mediaBlocked = false;
370374
371 switch (node.tagName) {375 switch (node.tagName) {
@@ -493,6 +497,7 @@ export const event_types = {
493 // TODO: Naming convention is inconsistent with other events497 // TODO: Naming convention is inconsistent with other events
494 CHARACTER_DELETED: 'characterDeleted',498 CHARACTER_DELETED: 'characterDeleted',
495 CHARACTER_DUPLICATED: 'character_duplicated',499 CHARACTER_DUPLICATED: 'character_duplicated',
500 CHARACTER_RENAMED: 'character_renamed',
496 /** @deprecated The event is aliased to STREAM_TOKEN_RECEIVED. */501 /** @deprecated The event is aliased to STREAM_TOKEN_RECEIVED. */
497 SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received',502 SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received',
498 STREAM_TOKEN_RECEIVED: 'stream_token_received',503 STREAM_TOKEN_RECEIVED: 'stream_token_received',
@@ -507,7 +512,7 @@ export const event_types = {
507 TOOL_CALLS_RENDERED: 'tool_calls_rendered',512 TOOL_CALLS_RENDERED: 'tool_calls_rendered',
508};513};
509514
510export const eventSource = new EventEmitter();515export const eventSource = new EventEmitter([event_types.APP_READY]);
511516
512eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands);517eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands);
513518
@@ -1027,12 +1032,22 @@ export function setAnimationDuration(ms = null) {
1027 document.documentElement.style.setProperty('--animation-duration', `${animation_duration}ms`);1032 document.documentElement.style.setProperty('--animation-duration', `${animation_duration}ms`);
1028}1033}
10291034
1035/**
1036 * Sets the currently active character
1037 * @param {object|number|string} [entityOrKey] - An entity with id property (character, group, tag), or directly an id or tag key. If not provided, the active character is reset to `null`.
1038 */
1030export function setActiveCharacter(entityOrKey) {1039export function setActiveCharacter(entityOrKey) {
1031 active_character = getTagKeyForEntity(entityOrKey);1040 active_character = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
1041 if (active_character) active_group = null;
1032}1042}
10331043
1044/**
1045 * Sets the currently active group.
1046 * @param {object|number|string} [entityOrKey] - An entity with id property (character, group, tag), or directly an id or tag key. If not provided, the active group is reset to `null`.
1047 */
1034export function setActiveGroup(entityOrKey) {1048export function setActiveGroup(entityOrKey) {
1035 active_group = getTagKeyForEntity(entityOrKey);1049 active_group = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
1050 if (active_group) active_character = null;
1036}1051}
10371052
1038/**1053/**
@@ -3223,6 +3238,7 @@ class StreamingProcessor {
32233238
3224 // Update reasoning3239 // Update reasoning
3225 await this.reasoningHandler.process(messageId, mesChanged);3240 await this.reasoningHandler.process(messageId, mesChanged);
3241 processedText = chat[messageId]['mes'];
32263242
3227 // Token count update.3243 // Token count update.
3228 const tokenCountText = this.reasoningHandler.reasoning + processedText;3244 const tokenCountText = this.reasoningHandler.reasoning + processedText;
@@ -3373,7 +3389,7 @@ class StreamingProcessor {
3373 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));3389 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));
3374 }3390 }
3375 // Get the updated reasoning string into the handler3391 // Get the updated reasoning string into the handler
3376 this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning ?? '');3392 this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning);
3377 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);3393 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
3378 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));3394 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
3379 }3395 }
@@ -6241,9 +6257,35 @@ export async function renameCharacter(name = null, { silent = false, renameChats
6241 const data = await response.json();6257 const data = await response.json();
6242 const newAvatar = data.avatar;6258 const newAvatar = data.avatar;
62436259
6244 // Replace tags list6260 const oldName = getCharaFilename(null, { manualAvatarKey: oldAvatar });
6261 const newName = getCharaFilename(null, { manualAvatarKey: newAvatar });
6262
6263 // Replace other auxillery fields where was referenced by avatar key
6264 // Tag List
6245 renameTagKey(oldAvatar, newAvatar);6265 renameTagKey(oldAvatar, newAvatar);
62466266
6267 // Addtional lore books
6268 const charLore = world_info.charLore?.find(x => x.name == oldName);
6269 if (charLore) {
6270 charLore.name = newName;
6271 saveSettingsDebounced();
6272 }
6273
6274 // Char-bound Author's Notes
6275 const charNote = extension_settings.note.chara?.find(x => x.name == oldName);
6276 if (charNote) {
6277 charNote.name = newName;
6278 saveSettingsDebounced();
6279 }
6280
6281 // Update active character, if the current one was the currently active one
6282 if (active_character === oldAvatar) {
6283 active_character = newAvatar;
6284 saveSettingsDebounced();
6285 }
6286
6287 await eventSource.emit(event_types.CHARACTER_RENAMED, oldAvatar, newAvatar);
6288
6247 // Reload characters list6289 // Reload characters list
6248 await getCharacters();6290 await getCharacters();
62496291
@@ -10947,7 +10989,7 @@ jQuery(async function () {
10947 });10989 });
1094810990
10949 $(document).on('click', '.mes_edit_copy', async function () {10991 $(document).on('click', '.mes_edit_copy', async function () {
10950 const confirmation = await callGenericPopup('Create a copy of this message?', POPUP_TYPE.CONFIRM);10992 const confirmation = await callGenericPopup(t`Create a copy of this message?`, POPUP_TYPE.CONFIRM);
10951 if (!confirmation) {10993 if (!confirmation) {
10952 return;10994 return;
10953 }10995 }
public/scripts/RossAscends-mods.js+17 -2
@@ -280,17 +280,32 @@ async function RA_autoloadchat() {
280 // active character is the name, we should look it up in the character list and get the id280 // active character is the name, we should look it up in the character list and get the id
281 if (active_character !== null && active_character !== undefined) {281 if (active_character !== null && active_character !== undefined) {
282 const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character);282 const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character);
283 if (active_character_id !== null) {283 if (active_character_id !== -1) {
284 await selectCharacterById(String(active_character_id));284 await selectCharacterById(String(active_character_id));
285285
286 // Do a little tomfoolery to spoof the tag selector286 // Do a little tomfoolery to spoof the tag selector
287 const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`);287 const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`);
288 applyTagsOnCharacterSelect.call(selectedCharElement);288 applyTagsOnCharacterSelect.call(selectedCharElement);
289 } else {
290 setActiveCharacter(null);
291 saveSettingsDebounced();
292 console.warn(`Currently active character with ID ${active_character} not found. Resetting to no active character.`);
289 }293 }
290 }294 }
291295
292 if (active_group !== null && active_group !== undefined) {296 if (active_group !== null && active_group !== undefined) {
293 await openGroupById(String(active_group));297 if (active_character) {
298 console.warn('Active character and active group are both set. Only active character will be loaded. Resetting active group.');
299 setActiveGroup(null);
300 saveSettingsDebounced();
301 } else {
302 const result = await openGroupById(String(active_group));
303 if (!result) {
304 setActiveGroup(null);
305 saveSettingsDebounced();
306 console.warn(`Currently active group with ID ${active_group} not found. Resetting to no active group.`);
307 }
308 }
294 }309 }
295310
296 // if the character list hadn't been loaded yet, try again.311 // if the character list hadn't been loaded yet, try again.
public/scripts/chats.js+1 -1
@@ -1487,7 +1487,7 @@ jQuery(function () {
1487 ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),1487 ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),
1488 ];1488 ];
14891489
1490 download(JSON.stringify(chatToSave, null, 4), `Assistant - ${humanizedDateTime()}.json`, 'application/json');1490 download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json');
1491 });1491 });
14921492
1493 // Do not change. #attachFile is added by extension.1493 // Do not change. #attachFile is added by extension.
public/scripts/extensions.js+14 -4
@@ -154,8 +154,18 @@ export const extension_settings = {
154 refine_mode: false,154 refine_mode: false,
155 },155 },
156 expressions: {156 expressions: {
157 /** @type {number} see `EXPRESSION_API` */
158 api: undefined,
157 /** @type {string[]} */159 /** @type {string[]} */
158 custom: [],160 custom: [],
161 showDefault: false,
162 translate: false,
163 /** @type {string} */
164 fallback_expression: undefined,
165 /** @type {string} */
166 llmPrompt: undefined,
167 allowMultiple: true,
168 rerollIfSame: false,
159 },169 },
160 connectionManager: {170 connectionManager: {
161 selectedProfile: '',171 selectedProfile: '',
@@ -603,12 +613,12 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
603 }613 }
604614
605 let toggleElement = isActive || isDisabled ?615 let toggleElement = isActive || isDisabled ?
606 `<input type="checkbox" title="Click to toggle" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :616 '<input type="checkbox" title="' + t`Click to toggle` + `" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :
607 `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;617 `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;
608618
609 let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';619 let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" data-i18n="[title]Delete" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';
610 let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';620 let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';
611 let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';621 let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" data-i18n="[title]Move" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';
612 let modulesInfo = '';622 let modulesInfo = '';
613623
614 if (isActive && Array.isArray(manifest.optional)) {624 if (isActive && Array.isArray(manifest.optional)) {
@@ -616,7 +626,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
616 modules.forEach(x => optional.delete(x));626 modules.forEach(x => optional.delete(x));
617 if (optional.size > 0) {627 if (optional.size > 0) {
618 const optionalString = DOMPurify.sanitize([...optional].join(', '));628 const optionalString = DOMPurify.sanitize([...optional].join(', '));
619 modulesInfo = `<div class="extension_modules">Optional modules: <span class="optional">${optionalString}</span></div>`;629 modulesInfo = '<div class="extension_modules">' + t`Optional modules:` + ` <span class="optional">${optionalString}</span></div>`;
620 }630 }
621 } else if (!isDisabled) { // Neither active nor disabled631 } else if (!isDisabled) { // Neither active nor disabled
622 const requirements = new Set(manifest.requires);632 const requirements = new Set(manifest.requires);
public/scripts/extensions/assets/index.js+13 -15
@@ -10,6 +10,7 @@ import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
10import { executeSlashCommands } from '../../slash-commands.js';10import { executeSlashCommands } from '../../slash-commands.js';
11import { accountStorage } from '../../util/AccountStorage.js';11import { accountStorage } from '../../util/AccountStorage.js';
12import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';12import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
13import { t } from '../../i18n.js';
13export { MODULE_NAME };14export { MODULE_NAME };
1415
15const MODULE_NAME = 'assets';16const MODULE_NAME = 'assets';
@@ -59,11 +60,11 @@ const KNOWN_TYPES = {
59 'blip': 'Blip sounds',60 'blip': 'Blip sounds',
60};61};
6162
62function downloadAssetsList(url) {63async function downloadAssetsList(url) {
63 updateCurrentAssets().then(function () {64 updateCurrentAssets().then(async function () {
64 fetch(url, { cache: 'no-cache' })65 fetch(url, { cache: 'no-cache' })
65 .then(response => response.json())66 .then(response => response.json())
66 .then(json => {67 .then(async function(json) {
6768
68 availableAssets = {};69 availableAssets = {};
69 $('#assets_menu').empty();70 $('#assets_menu').empty();
@@ -84,10 +85,10 @@ function downloadAssetsList(url) {
8485
85 $('#assets_type_select').empty();86 $('#assets_type_select').empty();
86 $('#assets_search').val('');87 $('#assets_search').val('');
87 $('#assets_type_select').append($('<option />', { value: '', text: 'All' }));88 $('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
8889
89 for (const type of assetTypes) {90 for (const type of assetTypes) {
90 const option = $('<option />', { value: type, text: KNOWN_TYPES[type] || type });91 const option = $('<option />', { value: type, text: t([KNOWN_TYPES[type] || type]) });
91 $('#assets_type_select').append(option);92 $('#assets_type_select').append(option);
92 }93 }
9394
@@ -104,11 +105,7 @@ function downloadAssetsList(url) {
104 assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();105 assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();
105106
106 if (assetType == 'extension') {107 if (assetType == 'extension') {
107 assetTypeMenu.append(`108 assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
108 <div class="assets-list-git">
109 To download extensions from this page, you need to have <a href="https://git-scm.com/downloads" target="_blank">Git</a> installed.<br>
110 Click the <i class="fa-solid fa-sm fa-arrow-up-right-from-square"></i> icon to visit the Extension's repo for tips on how to use it.
111 </div>`);
112 }109 }
113110
114 for (const i in availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) {111 for (const i in availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) {
@@ -184,7 +181,7 @@ function downloadAssetsList(url) {
184 const displayName = DOMPurify.sanitize(asset['name'] || asset['id']);181 const displayName = DOMPurify.sanitize(asset['name'] || asset['id']);
185 const description = DOMPurify.sanitize(asset['description'] || '');182 const description = DOMPurify.sanitize(asset['description'] || '');
186 const url = isValidUrl(asset['url']) ? asset['url'] : '';183 const url = isValidUrl(asset['url']) ? asset['url'] : '';
187 const title = assetType === 'extension' ? `Extension repo/guide: ${url}` : 'Preview in browser';184 const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
188 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';185 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
189 const toolTag = assetType === 'extension' && asset['tool'];186 const toolTag = assetType === 'extension' && asset['tool'];
190187
@@ -195,9 +192,10 @@ function downloadAssetsList(url) {
195 <b>${displayName}</b>192 <b>${displayName}</b>
196 <a class="asset_preview" href="${url}" target="_blank" title="${title}">193 <a class="asset_preview" href="${url}" target="_blank" title="${title}">
197 <i class="fa-solid fa-sm ${previewIcon}"></i>194 <i class="fa-solid fa-sm ${previewIcon}"></i>
198 </a>195 </a>` +
199 ${toolTag ? '<span class="tag" title="Adds a function tool"><i class="fa-solid fa-sm fa-wrench"></i> Tool</span>' : ''}196 (toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +
200 </span>197 t`Tool` + '</span>' : '') +
198 `</span>
201 <small class="asset-description">199 <small class="asset-description">
202 ${description}200 ${description}
203 </small>201 </small>
@@ -435,7 +433,7 @@ jQuery(async () => {
435 const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;433 const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
436 const skipConfirm = accountStorage.getItem(rememberKey) === 'true';434 const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
437435
438 const confirmation = skipConfirm || await Popup.show.confirm('Loading Asset List', `<span>Are you sure you want to connect to the following url?</span><var>${url}</var>`, {436 const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '<span>' + t`Are you sure you want to connect to the following url?` + `</span><var>${url}</var>`, {
439 customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],437 customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
440 onClose: popup => {438 onClose: popup => {
441 if (popup.result) {439 if (popup.result) {
public/scripts/extensions/assets/installation.html+4 -0
@@ -0,0 +1,4 @@
1<div class="assets-list-git">
2 <span data-i18n="extension_install_1">To download extensions from this page, you need to have </span><a href="https://git-scm.com/downloads" target="_blank">Git</a><span data-i18n="extension_install_2"> installed.</span><br>
3 <span data-i18n="extension_install_3">Click the </span><i class="fa-solid fa-sm fa-arrow-up-right-from-square"></i><span data-i18n="extension_install_4"> icon to visit the Extension's repo for tips on how to use it.</span>
4</div>
\ No newline at end of file4 \ No newline at end of file
public/scripts/extensions/assets/window.html+1 -1
@@ -33,7 +33,7 @@ To install a single 3rd party extension, use the &quot;Install Extensions&quot;
33 <div id="assets_filters" class="flex-container">33 <div id="assets_filters" class="flex-container">
34 <select id="assets_type_select" class="text_pole flex1">34 <select id="assets_type_select" class="text_pole flex1">
35 </select>35 </select>
36 <input id="assets_search" class="text_pole flex1" placeholder="Search" type="search">36 <input id="assets_search" class="text_pole flex1" data-i18n="[placeholder]Search" placeholder="Search" type="search">
37 <div id="assets-characters-button" class="menu_button menu_button_icon">37 <div id="assets-characters-button" class="menu_button menu_button_icon">
38 <i class="fa-solid fa-image-portrait"></i>38 <i class="fa-solid fa-image-portrait"></i>
39 <span data-i18n="Characters">Characters</span>39 <span data-i18n="Characters">Characters</span>
public/scripts/extensions/expressions/index.js+788 -707
@@ -1,11 +1,11 @@
1import { Fuse } from '../../../lib.js';1import { Fuse } from '../../../lib.js';
22
3import { callPopup, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js';3import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
4import { dragElement, isMobile } from '../../RossAscends-mods.js';4import { dragElement, isMobile } from '../../RossAscends-mods.js';
5import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';5import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
6import { loadMovingUIState, power_user } from '../../power-user.js';6import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
7import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';7import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
8import { hideMutedSprites } from '../../group-chats.js';8import { hideMutedSprites, selected_group } from '../../group-chats.js';
9import { isJsonSchemaSupported } from '../../textgen-settings.js';9import { isJsonSchemaSupported } from '../../textgen-settings.js';
10import { debounce_timeout } from '../../constants.js';10import { debounce_timeout } from '../../constants.js';
11import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';11import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -15,16 +15,32 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
15import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';15import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
18import { Popup, POPUP_RESULT } from '../../popup.js';
19import { t } from '../../i18n.js';
18export { MODULE_NAME };20export { MODULE_NAME };
1921
22/**
23* @typedef {object} Expression Expression definition with label and file path
24* @property {string} label The label of the expression
25* @property {ExpressionImage[]} files One or more images to represent this expression
26*/
27
28/**
29 * @typedef {object} ExpressionImage An expression image
30 * @property {string} expression - The expression
31 * @property {boolean} [isCustom=false] - If the expression is added by user
32 * @property {string} fileName - The filename with extension
33 * @property {string} title - The title for the image
34 * @property {string} imageSrc - The image source / full path
35 * @property {'success' | 'additional' | 'failure'} type - The type of the image
36 */
37
20const MODULE_NAME = 'expressions';38const MODULE_NAME = 'expressions';
21const UPDATE_INTERVAL = 2000;39const UPDATE_INTERVAL = 2000;
22const STREAMING_UPDATE_INTERVAL = 10000;40const STREAMING_UPDATE_INTERVAL = 10000;
23const TALKINGCHECK_UPDATE_INTERVAL = 500;
24const DEFAULT_FALLBACK_EXPRESSION = 'joy';41const DEFAULT_FALLBACK_EXPRESSION = 'joy';
25const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';42const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';
26const DEFAULT_EXPRESSIONS = [43const DEFAULT_EXPRESSIONS = [
27 'talkinghead',
28 'admiration',44 'admiration',
29 'amusement',45 'amusement',
30 'anger',46 'anger',
@@ -54,6 +70,12 @@ const DEFAULT_EXPRESSIONS = [
54 'surprise',70 'surprise',
55 'neutral',71 'neutral',
56];72];
73
74const OPTION_NO_FALLBACK = '#none';
75const OPTION_EMOJI_FALLBACK = '#emoji';
76const RESET_SPRITE_LABEL = '#reset';
77
78
57/** @enum {number} */79/** @enum {number} */
58const EXPRESSION_API = {80const EXPRESSION_API = {
59 local: 0,81 local: 0,
@@ -65,35 +87,29 @@ const EXPRESSION_API = {
65let expressionsList = null;87let expressionsList = null;
66let lastCharacter = undefined;88let lastCharacter = undefined;
67let lastMessage = null;89let lastMessage = null;
68let lastTalkingState = false;90/** @type {{[characterKey: string]: Expression[]}} */
69let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
70let spriteCache = {};91let spriteCache = {};
71let inApiCall = false;92let inApiCall = false;
72let lastServerResponseTime = 0;93let lastServerResponseTime = 0;
73export let lastExpression = {};
74
75function isTalkingHeadEnabled() {
76 return extension_settings.expressions.talkinghead && extension_settings.expressions.api == EXPRESSION_API.extras;
77}
7894
79/**95/** @type {{[characterName: string]: string}} */
80 * Returns the fallback expression if explicitly chosen, otherwise the default one96export let lastExpression = {};
81 * @returns {string} expression name
82 */
83function getFallbackExpression() {
84 return extension_settings.expressions.fallback_expression ?? DEFAULT_FALLBACK_EXPRESSION;
85}
8697
87/**98/**
88 * Toggles Talkinghead mode on/off.99 * Returns a placeholder image object for a given expression
89 *100 * @param {string} expression - The expression label
90 * Implements the `/th` slash command, which is meant to be bound to a Quick Reply button101 * @param {boolean} [isCustom=false] - Whether the expression is custom
91 * as a quick way to switch Talkinghead on or off (e.g. to conserve GPU resources when AFK102 * @returns {ExpressionImage} The placeholder image object
92 * for a long time).
93 */103 */
94function toggleTalkingHeadCommand(_) {104function getPlaceholderImage(expression, isCustom = false) {
95 setTalkingHeadState(!extension_settings.expressions.talkinghead);105 return {
96 return String(extension_settings.expressions.talkinghead);106 expression: expression,
107 isCustom: isCustom,
108 title: 'No Image',
109 type: 'failure',
110 fileName: 'No-Image-Placeholder.svg',
111 imageSrc: '/img/No-Image-Placeholder.svg',
112 };
97}113}
98114
99function isVisualNovelMode() {115function isVisualNovelMode() {
@@ -108,21 +124,21 @@ async function forceUpdateVisualNovelMode() {
108124
109const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);125const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);
110126
111async function updateVisualNovelMode(name, expression) {127async function updateVisualNovelMode(spriteFolderName, expression) {
112 const container = $('#visual-novel-wrapper');128 const vnContainer = $('#visual-novel-wrapper');
113129
114 await visualNovelRemoveInactive(container);130 await visualNovelRemoveInactive(vnContainer);
115131
116 const setSpritePromises = await visualNovelSetCharacterSprites(container, name, expression);132 const setSpritePromises = await visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression);
117133
118 // calculate layer indices based on recent messages134 // calculate layer indices based on recent messages
119 await visualNovelUpdateLayers(container);135 await visualNovelUpdateLayers(vnContainer);
120136
121 await Promise.allSettled(setSpritePromises);137 await Promise.allSettled(setSpritePromises);
122138
123 // update again based on new sprites139 // update again based on new sprites
124 if (setSpritePromises.length > 0) {140 if (setSpritePromises.length > 0) {
125 await visualNovelUpdateLayers(container);141 await visualNovelUpdateLayers(vnContainer);
126 }142 }
127}143}
128144
@@ -153,52 +169,60 @@ async function visualNovelRemoveInactive(container) {
153 await Promise.allSettled(removeInactiveCharactersPromises);169 await Promise.allSettled(removeInactiveCharactersPromises);
154}170}
155171
156async function visualNovelSetCharacterSprites(container, name, expression) {172/**
173 * Sets the character sprites for visual novel mode based on the provided container, name, and expression.
174 *
175 * @param {JQuery<HTMLElement>} vnContainer - The container element where the sprites will be set
176 * @param {string} spriteFolderName - The name of the sprite folder
177 * @param {string} expression - The expression to set for the characters
178 * @returns {Promise<Array>} - An array of promises that resolve when the sprites are set
179 */
180async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) {
181 const originalExpression = expression;
157 const context = getContext();182 const context = getContext();
158 const group = context.groups.find(x => x.id == context.groupId);183 const group = context.groups.find(x => x.id == context.groupId);
159 const labels = await getExpressionsList();
160184
161 const createCharacterPromises = [];
162 const setSpritePromises = [];185 const setSpritePromises = [];
163186
164 for (const avatar of group.members) {187 for (const avatar of group.members) {
165 const isDisabled = group.disabled_members.includes(avatar);
166
167 // skip disabled characters188 // skip disabled characters
189 const isDisabled = group.disabled_members.includes(avatar);
168 if (isDisabled && hideMutedSprites) {190 if (isDisabled && hideMutedSprites) {
169 continue;191 continue;
170 }192 }
171193
172 const character = context.characters.find(x => x.avatar == avatar);194 const character = context.characters.find(x => x.avatar == avatar);
173
174 if (!character) {195 if (!character) {
175 continue;196 continue;
176 }197 }
177198
178 const spriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);199 const expressionImage = vnContainer.find(`.expression-holder[data-avatar="${avatar}"]`);
200 /** @type {JQuery<HTMLElement>} */
201 let img;
202
203 const memberSpriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
179204
180 // download images if not downloaded yet205 // download images if not downloaded yet
181 if (spriteCache[spriteFolderName] === undefined) {206 if (spriteCache[memberSpriteFolderName] === undefined) {
182 spriteCache[spriteFolderName] = await getSpritesList(spriteFolderName);207 spriteCache[memberSpriteFolderName] = await getSpritesList(memberSpriteFolderName);
183 }208 }
184209
185 const sprites = spriteCache[spriteFolderName];210 const prevExpressionSrc = expressionImage.find('img').attr('src') || null;
186 const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
187 const defaultExpression = getFallbackExpression();
188 const defaultSpritePath = sprites.find(x => x.label === defaultExpression)?.path;
189 const noSprites = sprites.length === 0;
190211
191 if (expressionImage.length > 0) {212 if (!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) {
192 if (name == spriteFolderName) {213 expression = await getLastMessageSprite(avatar);
193 await validateImages(spriteFolderName, true);214 }
194 setExpressionOverrideHtml(true); // <= force clear expression override input
195 const currentSpritePath = labels.includes(expression) ? sprites.find(x => x.label === expression)?.path : '';
196215
197 const path = currentSpritePath || defaultSpritePath || '';216 const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc });
198 const img = expressionImage.find('img');217 if (expressionImage.length) {
218 if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) {
219 await validateImages(memberSpriteFolderName, true);
220 setExpressionOverrideHtml(true); // <= force clear expression override input
221 const path = spriteFile?.imageSrc || '';
222 img = expressionImage.find('img');
199 await setImage(img, path);223 await setImage(img, path);
200 }224 }
201 expressionImage.toggleClass('hidden', noSprites);225 expressionImage.toggleClass('hidden', !spriteFile);
202 } else {226 } else {
203 const template = $('#expression-holder').clone();227 const template = $('#expression-holder').clone();
204 template.attr('id', `expression-${avatar}`);228 template.attr('id', `expression-${avatar}`);
@@ -206,21 +230,49 @@ async function visualNovelSetCharacterSprites(container, name, expression) {
206 template.find('.drag-grabber').attr('id', `expression-${avatar}header`);230 template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
207 $('#visual-novel-wrapper').append(template);231 $('#visual-novel-wrapper').append(template);
208 dragElement($(template[0]));232 dragElement($(template[0]));
209 template.toggleClass('hidden', noSprites);233 template.toggleClass('hidden', !spriteFile);
210 await setImage(template.find('img'), defaultSpritePath || '');234 img = template.find('img');
235 await setImage(img, spriteFile?.imageSrc || '');
211 const fadeInPromise = new Promise(resolve => {236 const fadeInPromise = new Promise(resolve => {
212 template.fadeIn(250, () => resolve());237 template.fadeIn(250, () => resolve());
213 });238 });
214 createCharacterPromises.push(fadeInPromise);239 setSpritePromises.push(fadeInPromise);
215 const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
216 setSpritePromises.push(setSpritePromise);
217 }240 }
241
242 if (!img) {
243 continue;
244 }
245
246 img.attr('data-sprite-folder-name', spriteFolderName);
247 img.attr('data-expression', expression);
248 img.attr('data-sprite-filename', spriteFile?.fileName || null);
249 img.attr('title', expression);
250
251 if (spriteFile) console.info(`Expression set for group member ${character.name}`, { expression: spriteFile.expression, file: spriteFile.fileName });
252 else if (expressionImage.length) console.info(`Expression unset for group member ${character.name} - No sprite found`, { expression: expression });
253 else console.info(`Expression not available for group member ${character.name}`, { expression: expression });
218 }254 }
219255
220 await Promise.allSettled(createCharacterPromises);
221 return setSpritePromises;256 return setSpritePromises;
222}257}
223258
259/**
260 * Classifies the text of the latest message and returns the expression label.
261 * @param {string} avatar - The avatar of the character to get the last message for
262 * @returns {Promise<string>} - The expression label
263 */
264async function getLastMessageSprite(avatar) {
265 const context = getContext();
266 const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
267
268 if (lastMessage) {
269 const text = lastMessage.mes || '';
270 return await getExpressionLabel(text);
271 }
272
273 return null;
274}
275
224async function visualNovelUpdateLayers(container) {276async function visualNovelUpdateLayers(container) {
225 const context = getContext();277 const context = getContext();
226 const group = context.groups.find(x => x.id == context.groupId);278 const group = context.groups.find(x => x.id == context.groupId);
@@ -256,11 +308,17 @@ async function visualNovelUpdateLayers(container) {
256 const containerWidth = container.width();308 const containerWidth = container.width();
257 const pivotalPoint = containerWidth * 0.5;309 const pivotalPoint = containerWidth * 0.5;
258310
259 let images = $('#visual-novel-wrapper .expression-holder');311 let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction);
260 let imagesWidth = [];312 let imagesWidth = [];
261313
262 images.sort(sortFunction).each(function () {314 for (const image of images) {
263 imagesWidth.push($(this).width());315 if (image instanceof HTMLImageElement && !image.complete) {
316 await new Promise(resolve => image.addEventListener('load', resolve, { once: true }));
317 }
318 }
319
320 images.forEach(image => {
321 imagesWidth.push($(image).width());
264 });322 });
265323
266 let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);324 let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
@@ -274,7 +332,7 @@ async function visualNovelUpdateLayers(container) {
274 currentPosition = 0; // Reset the initial position to 0332 currentPosition = 0; // Reset the initial position to 0
275 }333 }
276334
277 images.sort(sortFunction).each((index, current) => {335 images.forEach((current, index) => {
278 const element = $(current);336 const element = $(current);
279 const elementID = element.attr('id');337 const elementID = element.attr('id');
280338
@@ -294,9 +352,15 @@ async function visualNovelUpdateLayers(container) {
294 element.show();352 element.show();
295353
296 const promise = new Promise(resolve => {354 const promise = new Promise(resolve => {
355 if (power_user.reduced_motion) {
356 element.css('left', currentPosition + 'px');
357 requestAnimationFrame(() => resolve());
358 }
359 else {
297 element.animate({ left: currentPosition + 'px' }, 500, () => {360 element.animate({ left: currentPosition + 'px' }, 500, () => {
298 resolve();361 resolve();
299 });362 });
363 }
300 });364 });
301365
302 currentPosition += imagesWidth[index];366 currentPosition += imagesWidth[index];
@@ -307,23 +371,12 @@ async function visualNovelUpdateLayers(container) {
307 await Promise.allSettled(setLayerIndicesPromises);371 await Promise.allSettled(setLayerIndicesPromises);
308}372}
309373
310async function setLastMessageSprite(img, avatar, labels) {374/**
311 const context = getContext();375 * Sets the expression for the given character image.
312 const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));376 * @param {JQuery<HTMLElement>} img - The image element to set the image on
313377 * @param {string} path - The path to the image
314 if (lastMessage) {378 * @returns {Promise<void>} - A promise that resolves when the image is set
315 const text = lastMessage.mes || '';379 */
316 const spriteFolderName = getSpriteFolderName(lastMessage, lastMessage.name);
317 const sprites = spriteCache[spriteFolderName] || [];
318 const label = await getExpressionLabel(text);
319 const path = labels.includes(label) ? sprites.find(x => x.label === label)?.path : '';
320
321 if (path) {
322 setImage(img, path);
323 }
324 }
325}
326
327async function setImage(img, path) {380async function setImage(img, path) {
328 // Cohee: If something goes wrong, uncomment this to return to the old behavior381 // Cohee: If something goes wrong, uncomment this to return to the old behavior
329 /*382 /*
@@ -340,7 +393,7 @@ async function setImage(img, path) {
340 return new Promise(resolve => {393 return new Promise(resolve => {
341 const prevExpressionSrc = img.attr('src');394 const prevExpressionSrc = img.attr('src');
342 const expressionClone = img.clone();395 const expressionClone = img.clone();
343 const originalId = img.attr('id');396 const originalId = img.data('filename');
344397
345 //only swap expressions when necessary398 //only swap expressions when necessary
346 if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {399 if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
@@ -348,7 +401,7 @@ async function setImage(img, path) {
348 expressionClone.addClass('expression-clone');401 expressionClone.addClass('expression-clone');
349 //make invisible and remove id to prevent double ids402 //make invisible and remove id to prevent double ids
350 //must be made invisible to start because they share the same Z-index403 //must be made invisible to start because they share the same Z-index
351 expressionClone.attr('id', '').css({ opacity: 0 });404 expressionClone.data('filename', '').css({ opacity: 0 });
352 //add new sprite path to clone src405 //add new sprite path to clone src
353 expressionClone.attr('src', path);406 expressionClone.attr('src', path);
354 //add invisible clone to html407 //add invisible clone to html
@@ -384,14 +437,18 @@ async function setImage(img, path) {
384 //remove old expression437 //remove old expression
385 img.remove();438 img.remove();
386 //replace ID so it becomes the new 'original' expression for next change439 //replace ID so it becomes the new 'original' expression for next change
387 expressionClone.attr('id', originalId);440 expressionClone.data('filename', originalId);
388 expressionClone.removeClass('expression-animating');441 expressionClone.removeClass('expression-animating');
389442
390 // Reset the expression holder min height and width443 // Reset the expression holder min height and width
391 expressionHolder.css('min-width', 100);444 expressionHolder.css('min-width', 100);
392 expressionHolder.css('min-height', 100);445 expressionHolder.css('min-height', 100);
393446
447 if (expressionClone.prop('complete')) {
394 resolve();448 resolve();
449 } else {
450 expressionClone.one('load', () => resolve());
451 }
395 });452 });
396453
397 expressionClone.removeClass('expression-clone');454 expressionClone.removeClass('expression-clone');
@@ -410,216 +467,9 @@ async function setImage(img, path) {
410 });467 });
411}468}
412469
413function onExpressionsShowDefaultInput() {470async function moduleWorker({ newChat = false } = {}) {
414 const value = $(this).prop('checked');
415 extension_settings.expressions.showDefault = value;
416 saveSettingsDebounced();
417
418 const existingImageSrc = $('img.expression').prop('src');
419 if (existingImageSrc !== undefined) { //if we have an image in src
420 if (!value && existingImageSrc.includes('/img/default-expressions/')) { //and that image is from /img/ (default)
421 $('img.expression').prop('src', ''); //remove it
422 lastMessage = null;
423 }
424 if (value) {
425 lastMessage = null;
426 }
427 }
428}
429
430/**
431 * Stops animating Talkinghead.
432 */
433async function unloadTalkingHead() {
434 if (!modules.includes('talkinghead')) {
435 console.debug('talkinghead module is disabled');
436 return;
437 }
438 console.debug('expressions: Stopping Talkinghead');
439
440 try {
441 const url = new URL(getApiUrl());
442 url.pathname = '/api/talkinghead/unload';
443 const loadResponse = await doExtrasFetch(url);
444 if (!loadResponse.ok) {
445 throw new Error(loadResponse.statusText);
446 }
447 //console.log(`Response: ${loadResponseText}`);
448 } catch (error) {
449 //console.error(`Error unloading - ${error}`);
450 }
451}
452
453/**
454 * Posts `talkinghead.png` of the current character to the talkinghead module in SillyTavern-extras, to start animating it.
455 */
456async function loadTalkingHead() {
457 if (!modules.includes('talkinghead')) {
458 console.debug('talkinghead module is disabled');
459 return;
460 }
461 console.debug('expressions: Starting Talkinghead');
462
463 const spriteFolderName = getSpriteFolderName();
464
465 const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
466 const emotionsSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_emotions.json`;
467 const animatorSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_animator.json`;
468
469 try {
470 const spriteResponse = await fetch(talkingheadPath);
471
472 if (!spriteResponse.ok) {
473 throw new Error(spriteResponse.statusText);
474 }
475
476 const spriteBlob = await spriteResponse.blob();
477 const spriteFile = new File([spriteBlob], 'talkinghead.png', { type: 'image/png' });
478 const formData = new FormData();
479 formData.append('file', spriteFile);
480
481 const url = new URL(getApiUrl());
482 url.pathname = '/api/talkinghead/load';
483
484 const loadResponse = await doExtrasFetch(url, {
485 method: 'POST',
486 body: formData,
487 });
488
489 if (!loadResponse.ok) {
490 throw new Error(loadResponse.statusText);
491 }
492
493 const loadResponseText = await loadResponse.text();
494 console.log(`Load talkinghead response: ${loadResponseText}`);
495
496 // Optional: per-character emotion templates
497 let emotionsSettings;
498 try {
499 const emotionsResponse = await fetch(emotionsSettingsPath);
500 if (emotionsResponse.ok) {
501 emotionsSettings = await emotionsResponse.json();
502 console.log(`Loaded ${emotionsSettingsPath}`);
503 } else {
504 throw new Error();
505 }
506 }
507 catch (error) {
508 emotionsSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
509 console.log(`No valid config at ${emotionsSettingsPath}, using server defaults`);
510 }
511 try {
512 const url = new URL(getApiUrl());
513 url.pathname = '/api/talkinghead/load_emotion_templates';
514 const apiResult = await doExtrasFetch(url, {
515 method: 'POST',
516 headers: {
517 'Content-Type': 'application/json',
518 'Bypass-Tunnel-Reminder': 'bypass',
519 },
520 body: JSON.stringify(emotionsSettings),
521 });
522
523 if (!apiResult.ok) {
524 throw new Error(apiResult.statusText);
525 }
526 }
527 catch (error) {
528 // it's ok if not supported
529 console.log('Failed to send _emotions.json (backend too old?), ignoring');
530 }
531
532 // Optional: per-character animator and postprocessor config
533 let animatorSettings;
534 try {
535 const animatorResponse = await fetch(animatorSettingsPath);
536 if (animatorResponse.ok) {
537 animatorSettings = await animatorResponse.json();
538 console.log(`Loaded ${animatorSettingsPath}`);
539 } else {
540 throw new Error();
541 }
542 }
543 catch (error) {
544 animatorSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
545 console.log(`No valid config at ${animatorSettingsPath}, using server defaults`);
546 }
547 try {
548 const url = new URL(getApiUrl());
549 url.pathname = '/api/talkinghead/load_animator_settings';
550 const apiResult = await doExtrasFetch(url, {
551 method: 'POST',
552 headers: {
553 'Content-Type': 'application/json',
554 'Bypass-Tunnel-Reminder': 'bypass',
555 },
556 body: JSON.stringify(animatorSettings),
557 });
558
559 if (!apiResult.ok) {
560 throw new Error(apiResult.statusText);
561 }
562 }
563 catch (error) {
564 // it's ok if not supported
565 console.log('Failed to send _animator.json (backend too old?), ignoring');
566 }
567 } catch (error) {
568 console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
569 }
570}
571
572function handleImageChange() {
573 const imgElement = document.querySelector('img#expression-image.expression');
574
575 if (!imgElement || !(imgElement instanceof HTMLImageElement)) {
576 console.log('Cannot find addExpressionImage()');
577 return;
578 }
579
580 if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
581 const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
582 $('#expression-holder').css({ display: '' });
583 if (imgElement.src !== talkingheadResultFeedSrc) {
584 const expressionImageElement = document.querySelector('.expression_list_image');
585
586 if (expressionImageElement && expressionImageElement instanceof HTMLImageElement) {
587 doExtrasFetch(expressionImageElement.src, {
588 method: 'HEAD',
589 })
590 .then(response => {
591 if (response.ok) {
592 imgElement.src = talkingheadResultFeedSrc;
593 }
594 })
595 .catch(error => {
596 console.error(error);
597 });
598 }
599 }
600 } else {
601 imgElement.src = ''; // remove in case char doesn't have expressions
602
603 // When switching Talkinghead off, force-set the character to the last known expression, if any.
604 // This preserves the same expression Talkinghead had at the moment it was switched off.
605 const charName = getContext().name2;
606 const last = lastExpression[charName];
607 const targetExpression = last ? last : getFallbackExpression();
608 setExpression(charName, targetExpression, true);
609 }
610}
611
612async function moduleWorker() {
613 const context = getContext();471 const context = getContext();
614472
615 // Hide and disable Talkinghead while not in extras
616 $('#image_type_block').toggle(extension_settings.expressions.api == EXPRESSION_API.extras);
617
618 if (extension_settings.expressions.api != EXPRESSION_API.extras && extension_settings.expressions.talkinghead) {
619 $('#image_type_toggle').prop('checked', false);
620 setTalkingHeadState(false);
621 }
622
623 // non-characters not supported473 // non-characters not supported
624 if (!context.groupId && context.characterId === undefined) {474 if (!context.groupId && context.characterId === undefined) {
625 removeExpression();475 removeExpression();
@@ -646,7 +496,7 @@ async function moduleWorker() {
646 }496 }
647497
648 const currentLastMessage = getLastCharacterMessage();498 const currentLastMessage = getLastCharacterMessage();
649 let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();499 let spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
650500
651 // character has no expressions or it is not loaded501 // character has no expressions or it is not loaded
652 if (Object.keys(spriteCache).length === 0) {502 if (Object.keys(spriteCache).length === 0) {
@@ -686,6 +536,10 @@ async function moduleWorker() {
686 offlineMode.css('display', 'none');536 offlineMode.css('display', 'none');
687 }537 }
688538
539 if (context.groupId && vnMode && newChat) {
540 await forceUpdateVisualNovelMode();
541 }
542
689 // Don't bother classifying if current char has no sprites and no default expressions are enabled543 // Don't bother classifying if current char has no sprites and no default expressions are enabled
690 if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {544 if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {
691 return;545 return;
@@ -732,11 +586,11 @@ async function moduleWorker() {
732 const force = !!context.groupId;586 const force = !!context.groupId;
733587
734 // Character won't be angry on you for swiping588 // Character won't be angry on you for swiping
735 if (currentLastMessage.mes == '...' && expressionsList.includes(getFallbackExpression())) {589 if (currentLastMessage.mes == '...' && expressionsList.includes(extension_settings.expressions.fallback_expression)) {
736 expression = getFallbackExpression();590 expression = extension_settings.expressions.fallback_expression;
737 }591 }
738592
739 await sendExpressionCall(spriteFolderName, expression, force, vnMode);593 await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
740 }594 }
741 catch (error) {595 catch (error) {
742 console.log(error);596 console.log(error);
@@ -749,91 +603,6 @@ async function moduleWorker() {
749 }603 }
750}604}
751605
752/**
753 * Starts/stops Talkinghead talking animation.
754 *
755 * Talking starts only when all the following conditions are met:
756 * - The LLM is currently streaming its output.
757 * - The AI's current last message is non-empty, and also not just '...' (as produced by a swipe).
758 * - The AI's current last message has changed from what we saw during the previous call.
759 *
760 * In all other cases, talking stops.
761 *
762 * A Talkinghead API call is made only when the talking state changes.
763 *
764 * Note that also the TTS system, if enabled, starts/stops the Talkinghead talking animation.
765 * See `talkingAnimation` in `SillyTavern/public/scripts/extensions/tts/index.js`.
766 */
767async function updateTalkingState() {
768 // Don't bother if Talkinghead is disabled or not loaded.
769 if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
770 return;
771 }
772
773 const context = getContext();
774 const currentLastMessage = getLastCharacterMessage();
775
776 try {
777 // TODO: Not sure if we need also "&& !context.groupId" here - the classify check in `moduleWorker`
778 // (that similarly checks the streaming processor state) does that for some reason.
779 // Talkinghead isn't currently designed to work with groups.
780 const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastTalkingStateMessage === currentLastMessage.mes);
781 const url = new URL(getApiUrl());
782 let newTalkingState;
783 if (context.streamingProcessor && !context.streamingProcessor.isFinished &&
784 currentLastMessage.mes.length !== 0 && currentLastMessage.mes !== '...' && lastMessageChanged) {
785 url.pathname = '/api/talkinghead/start_talking';
786 newTalkingState = true;
787 } else {
788 url.pathname = '/api/talkinghead/stop_talking';
789 newTalkingState = false;
790 }
791 try {
792 // Call the Talkinghead API only if the talking state changed.
793 if (newTalkingState !== lastTalkingState) {
794 console.debug(`updateTalkingState: calling ${url.pathname}`);
795 await doExtrasFetch(url);
796 }
797 }
798 catch (error) {
799 // it's ok if not supported
800 }
801 finally {
802 lastTalkingState = newTalkingState;
803 }
804 }
805 catch (error) {
806 // console.log(error);
807 }
808 finally {
809 lastTalkingStateMessage = currentLastMessage.mes;
810 }
811}
812
813/**
814 * Checks whether the current character has a talkinghead image available.
815 * @returns {Promise<boolean>} True if the character has a talkinghead image available, false otherwise.
816 */
817async function isTalkingHeadAvailable() {
818 let spriteFolderName = getSpriteFolderName();
819
820 try {
821 await validateImages(spriteFolderName);
822
823 let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
824 let talkingheadPath = talkingheadObj ? talkingheadObj.path : null;
825
826 if (talkingheadPath != null) {
827 return true;
828 } else {
829 await unloadTalkingHead();
830 return false;
831 }
832 } catch (err) {
833 return err;
834 }
835}
836
837function getSpriteFolderName(characterMessage = null, characterName = null) {606function getSpriteFolderName(characterMessage = null, characterName = null) {
838 const context = getContext();607 const context = getContext();
839 let spriteFolderName = characterName ?? context.name2;608 let spriteFolderName = characterName ?? context.name2;
@@ -848,33 +617,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
848 return spriteFolderName;617 return spriteFolderName;
849}618}
850619
851function setTalkingHeadState(newState) {
852 console.debug(`expressions: New talkinghead state: ${newState}`);
853 extension_settings.expressions.talkinghead = newState; // Store setting
854 saveSettingsDebounced();
855
856 if ([EXPRESSION_API.local, EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) {
857 return;
858 }
859
860 isTalkingHeadAvailable().then(result => {
861 if (result) {
862 //console.log("talkinghead exists!");
863
864 if (extension_settings.expressions.talkinghead) {
865 loadTalkingHead();
866 } else {
867 unloadTalkingHead();
868 }
869 handleImageChange(); // Change image as needed
870
871
872 } else {
873 //console.log("talkinghead does not exist.");
874 }
875 });
876}
877
878function getFolderNameByMessage(message) {620function getFolderNameByMessage(message) {
879 const context = getContext();621 const context = getContext();
880 let avatarPath = '';622 let avatarPath = '';
@@ -894,48 +636,55 @@ function getFolderNameByMessage(message) {
894 return folderName;636 return folderName;
895}637}
896638
897async function sendExpressionCall(name, expression, force, vnMode) {639/**
898 lastExpression[name.split('/')[0]] = expression;640 * Update the expression for the given character.
899 if (!vnMode) {641 *
642 * @param {string} spriteFolderName The character name, optionally with a sprite folder override, e.g. "folder/expression".
643 * @param {string} expression The expression label, e.g. "amusement", "joy", etc.
644 * @param {Object} [options] Additional options
645 * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression.
646 * @param {boolean} [options.vnMode=null] If true, the expression will be sent in Visual Novel mode. If null, it will be determined by the current chat mode.
647 * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
648 */
649export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
650 lastExpression[spriteFolderName.split('/')[0]] = expression;
651 if (vnMode === null) {
900 vnMode = isVisualNovelMode();652 vnMode = isVisualNovelMode();
901 }653 }
902654
903 if (vnMode) {655 if (vnMode) {
904 await updateVisualNovelMode(name, expression);656 await updateVisualNovelMode(spriteFolderName, expression);
905 } else {657 } else {
906 setExpression(name, expression, force);658 setExpression(spriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
907 }659 }
908}660}
909661
910async function setSpriteSetCommand(_, folder) {662async function setSpriteFolderCommand(_, folder) {
911 if (!folder) {663 if (!folder) {
912 console.log('Clearing sprite set');664 console.log('Clearing sprite set');
913 folder = '';665 folder = '';
914 }666 }
915667
916 if (folder.startsWith('/') || folder.startsWith('\\')) {668 if (folder.startsWith('/') || folder.startsWith('\\')) {
917 folder = folder.slice(1);
918
919 const currentLastMessage = getLastCharacterMessage();669 const currentLastMessage = getLastCharacterMessage();
670 folder = folder.slice(1);
920 folder = `${currentLastMessage.name}/${folder}`;671 folder = `${currentLastMessage.name}/${folder}`;
921 }672 }
922673
923 $('#expression_override').val(folder.trim());674 $('#expression_override').val(folder.trim());
924 onClickExpressionOverrideButton();675 onClickExpressionOverrideButton();
925 // removeExpression();676
926 // moduleWorker();677 // No need to resend the expression, the folder override will automatically update the currently displayed one.
927 const vnMode = isVisualNovelMode();
928 await sendExpressionCall(folder, lastExpression, true, vnMode);
929 return '';678 return '';
930}679}
931680
932async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {681async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {
933 if (!text) {682 if (!text) {
934 toastr.warning('No text provided');683 toastr.error('No text provided');
935 return '';684 return '';
936 }685 }
937 if (api && !Object.keys(EXPRESSION_API).includes(api)) {686 if (api && !Object.keys(EXPRESSION_API).includes(api)) {
938 toastr.warning('Invalid API provided');687 toastr.error('Invalid API provided');
939 return '';688 return '';
940 }689 }
941690
@@ -951,37 +700,69 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
951 return label;700 return label;
952}701}
953702
954async function setSpriteSlashCommand(_, spriteId) {703/** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
955 if (!spriteId) {704async function setSpriteSlashCommand({ type }, searchTerm) {
956 console.log('No sprite id provided');705 type ??= 'expression';
706 searchTerm = searchTerm.trim().toLowerCase();
707 if (!searchTerm) {
708 toastr.error(t`No expression or sprite name provided`, t`Set Sprite`);
957 return '';709 return '';
958 }710 }
959711
960 spriteId = spriteId.trim().toLowerCase();712 const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
713 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
714
715 let label = searchTerm;
716
717 /** @type {string?} */
718 let spriteFile = null;
961719
962 // In Talkinghead mode, don't check for the existence of the sprite
963 // (emotion names are the same as for sprites, but it only needs "talkinghead.png").
964 const currentLastMessage = getLastCharacterMessage();
965 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
966 let label = spriteId;
967 if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
968 await validateImages(spriteFolderName);720 await validateImages(spriteFolderName);
969721
970 // Fuzzy search for sprite722 // Handle reset as a special term and just reset the sprite via expression call
971 const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });723 if (searchTerm === RESET_SPRITE_LABEL) {
972 const results = fuse.search(spriteId);724 await sendExpressionCall(spriteFolderName, label, { force: true });
973 const spriteItem = results[0]?.item;725 return lastExpression[spriteFolderName] ?? '';
726 }
727
728 switch (type) {
729 case 'expression': {
730 // Fuzzy search for expression
731 const existingExpressions = getCachedExpressions().map(x => ({ label: x }));
732 const results = performFuzzySearch('expression-expressions', existingExpressions, [
733 { name: 'label', weight: 1 },
734 ], searchTerm);
735 const matchedExpression = results[0]?.item;
736 if (!matchedExpression) {
737 toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`);
738 return '';
739 }
974740
975 if (!spriteItem) {741 label = matchedExpression.label;
976 console.log('No sprite found for search term ' + spriteId);742 break;
743 }
744 case 'sprite': {
745 // Fuzzy search for sprite file
746 const sprites = spriteCache[spriteFolderName].map(x => x.files).flat();
747 const results = performFuzzySearch('expression-expressions', sprites, [
748 { name: 'title', weight: 1 },
749 { name: 'fileName', weight: 1 },
750 ], searchTerm);
751 const matchedSprite = results[0]?.item;
752 if (!matchedSprite) {
753 toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`);
977 return '';754 return '';
978 }755 }
979756
980 label = spriteItem.label;757 label = matchedSprite.expression;
758 spriteFile = matchedSprite.fileName;
759 break;
760 }
761 default: throw Error('Invalid sprite set type: ' + type);
981 }762 }
982763
983 const vnMode = isVisualNovelMode();764 await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
984 await sendExpressionCall(spriteFolderName, label, true, vnMode);765
985 return label;766 return label;
986}767}
987768
@@ -999,6 +780,21 @@ function spriteFolderNameFromCharacter(char) {
999}780}
1000781
1001/**782/**
783 * Generates a unique sprite name by appending an index to the given expression. *
784 * @param {string} expression - The base expression to be used as the prefix for the sprite name.
785 * @param {ExpressionImage[]} existingFiles - An array of existing file objects, each containing a fileName property.
786 * @returns {string} - A unique sprite name with the format "expression-index".
787 */
788function generateUniqueSpriteName(expression, existingFiles) {
789 let index = existingFiles.length;
790 let newSpriteName;
791 do {
792 newSpriteName = `${expression}-${index++}`;
793 } while (existingFiles.some(file => withoutExtension(file.fileName) === newSpriteName));
794 return newSpriteName;
795}
796
797/**
1002 * Slash command callback for /uploadsprite798 * Slash command callback for /uploadsprite
1003 *799 *
1004 * label= is required800 * label= is required
@@ -1011,16 +807,29 @@ function spriteFolderNameFromCharacter(char) {
1011 * @param {object} args807 * @param {object} args
1012 * @param {string} args.name Character name or avatar key, passed through findChar808 * @param {string} args.name Character name or avatar key, passed through findChar
1013 * @param {string} args.label Expression label809 * @param {string} args.label Expression label
1014 * @param {string} args.folder Sprite folder path, processed using backslash rules810 * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules
811 * @param {string?} [args.spriteName=null] Optional sprite name
1015 * @param {string} imageUrl Image URI to fetch and upload812 * @param {string} imageUrl Image URI to fetch and upload
1016 * @returns {Promise<void>}813 * @returns {Promise<string>} the sprite name
1017 */814 */
1018async function uploadSpriteCommand({ name, label, folder }, imageUrl) {815async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
1019 if (!imageUrl) throw new Error('Image URL is required');816 if (!imageUrl) throw new Error('Image URL is required');
1020 if (!label || typeof label !== 'string') throw new Error('Expression label is required');817 if (!label || typeof label !== 'string') {
818 toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
819 return '';
820 }
1021821
1022 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();822 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
1023 if (!label) throw new Error('Expression label must contain at least one letter');823 if (!label) {
824 toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`);
825 return '';
826 }
827
828 spriteName = spriteName || label;
829 if (!validateExpressionSpriteName(label, spriteName)) {
830 toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`);
831 return '';
832 }
1024833
1025 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;834 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
1026 const char = findChar({ name });835 const char = findChar({ name });
@@ -1041,6 +850,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1041 formData.append('name', folder); // this is the folder or character name850 formData.append('name', folder); // this is the folder or character name
1042 formData.append('label', label); // this is the expression label851 formData.append('label', label); // this is the expression label
1043 formData.append('avatar', file); // this is the image file852 formData.append('avatar', file); // this is the image file
853 formData.append('spriteName', spriteName); // this is a redundant comment
1044854
1045 await handleFileUpload('/api/sprites/upload', formData);855 await handleFileUpload('/api/sprites/upload', formData);
1046 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);856 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
@@ -1048,6 +858,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1048 console.error(`[${MODULE_NAME}] Error uploading file:`, error);858 console.error(`[${MODULE_NAME}] Error uploading file:`, error);
1049 throw error;859 throw error;
1050 }860 }
861
862 return spriteName;
1051}863}
1052864
1053/**865/**
@@ -1159,7 +971,7 @@ function getJsonSchema(emotions) {
1159function onTextGenSettingsReady(args) {971function onTextGenSettingsReady(args) {
1160 // Only call if inside an API call972 // Only call if inside an API call
1161 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {973 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {
1162 const emotions = DEFAULT_EXPRESSIONS.filter((e) => e != 'talkinghead');974 const emotions = DEFAULT_EXPRESSIONS;
1163 Object.assign(args, {975 Object.assign(args, {
1164 top_k: 1,976 top_k: 1,
1165 stop: [],977 stop: [],
@@ -1177,16 +989,16 @@ function onTextGenSettingsReady(args) {
1177 * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.989 * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.
1178 * @param {object} [options={}] - Optional arguments.990 * @param {object} [options={}] - Optional arguments.
1179 * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.991 * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.
1180 * @returns {Promise<string>} - The label of the expression.992 * @returns {Promise<string?>} - The label of the expression.
1181 */993 */
1182export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) {994export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) {
1183 // Return if text is undefined, saving a costly fetch request995 // Return if text is undefined, saving a costly fetch request
1184 if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {996 if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {
1185 return getFallbackExpression();997 return extension_settings.expressions.fallback_expression;
1186 }998 }
1187999
1188 if (extension_settings.expressions.translate && typeof window['translate'] === 'function') {1000 if (extension_settings.expressions.translate && typeof globalThis.translate === 'function') {
1189 text = await window['translate'](text, 'en');1001 text = await globalThis.translate(text, 'en');
1190 }1002 }
11911003
1192 text = sampleClassifyText(text);1004 text = sampleClassifyText(text);
@@ -1212,7 +1024,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
1212 await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);1024 await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
1213 } catch (error) {1025 } catch (error) {
1214 console.warn('No LLM connection. Using fallback expression', error);1026 console.warn('No LLM connection. Using fallback expression', error);
1215 return getFallbackExpression();1027 return extension_settings.expressions.fallback_expression;
1216 }1028 }
12171029
1218 const expressionsList = await getExpressionsList();1030 const expressionsList = await getExpressionsList();
@@ -1225,7 +1037,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
1225 case EXPRESSION_API.webllm: {1037 case EXPRESSION_API.webllm: {
1226 if (!isWebLlmSupported()) {1038 if (!isWebLlmSupported()) {
1227 console.warn('WebLLM is not supported. Using fallback expression');1039 console.warn('WebLLM is not supported. Using fallback expression');
1228 return getFallbackExpression();1040 return extension_settings.expressions.fallback_expression;
1229 }1041 }
12301042
1231 const expressionsList = await getExpressionsList();1043 const expressionsList = await getExpressionsList();
@@ -1258,9 +1070,9 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
1258 } break;1070 } break;
1259 }1071 }
1260 } catch (error) {1072 } catch (error) {
1261 toastr.info('Could not classify expression. Check the console or your backend for more information.');1073 toastr.error('Could not classify expression. Check the console or your backend for more information.');
1262 console.error(error);1074 console.error(error);
1263 return getFallbackExpression();1075 return extension_settings.expressions.fallback_expression;
1264 }1076 }
1265}1077}
12661078
@@ -1288,75 +1100,155 @@ function removeExpression() {
1288 $('#no_chat_expressions').show();1100 $('#no_chat_expressions').show();
1289}1101}
12901102
1291async function validateImages(character, forceRedrawCached) {1103/**
1292 if (!character) {1104 * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw.
1105 * @param {string} spriteFolderName - The character sprite folder to validate
1106 * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before
1107 */
1108async function validateImages(spriteFolderName, forceRedrawCached = false) {
1109 if (!spriteFolderName) {
1293 return;1110 return;
1294 }1111 }
12951112
1296 const labels = await getExpressionsList();1113 const labels = await getExpressionsList();
12971114
1298 if (spriteCache[character]) {1115 if (spriteCache[spriteFolderName]) {
1299 if (forceRedrawCached && $('#image_list').data('name') !== character) {1116 if (forceRedrawCached && $('#image_list').data('name') !== spriteFolderName) {
1300 console.debug('force redrawing character sprites list');1117 console.debug('force redrawing character sprites list');
1301 await drawSpritesList(character, labels, spriteCache[character]);1118 await drawSpritesList(spriteFolderName, labels, spriteCache[spriteFolderName]);
1302 }1119 }
13031120
1304 return;1121 return;
1305 }1122 }
13061123
1307 const sprites = await getSpritesList(character);1124 const sprites = await getSpritesList(spriteFolderName);
1308 let validExpressions = await drawSpritesList(character, labels, sprites);1125 let validExpressions = await drawSpritesList(spriteFolderName, labels, sprites);
1309 spriteCache[character] = validExpressions;1126 spriteCache[spriteFolderName] = validExpressions;
1127}
1128
1129/**
1130 * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting
1131 * @param {{ path: string, label: string }} sprite
1132 * @returns {ExpressionImage}
1133 */
1134function getExpressionImageData(sprite) {
1135 const fileName = sprite.path.split('/').pop().split('?')[0];
1136 const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
1137 return {
1138 expression: sprite.label,
1139 fileName: fileName,
1140 title: fileNameWithoutExtension,
1141 imageSrc: sprite.path,
1142 type: 'success',
1143 isCustom: extension_settings.expressions.custom?.includes(sprite.label),
1144 };
1310}1145}
13111146
1312async function drawSpritesList(character, labels, sprites) {1147/**
1148 * Populate the character expression list with sprites for the given character.
1149 * @param {string} spriteFolderName - The name of the character to populate the list for
1150 * @param {string[]} labels - An array of expression labels that are valid
1151 * @param {Expression[]} sprites - An array of sprites
1152 * @returns {Promise<Expression[]>} An array of valid expression labels
1153 */
1154async function drawSpritesList(spriteFolderName, labels, sprites) {
1155 /** @type {Expression[]} */
1313 let validExpressions = [];1156 let validExpressions = [];
1157
1314 $('#no_chat_expressions').hide();1158 $('#no_chat_expressions').hide();
1315 $('#open_chat_expressions').show();1159 $('#open_chat_expressions').show();
1316 $('#image_list').empty();1160 $('#image_list').empty();
1317 $('#image_list').data('name', character);1161 $('#image_list').data('name', spriteFolderName);
1318 $('#image_list_header_name').text(character);1162 $('#image_list_header_name').text(spriteFolderName);
13191163
1320 if (!Array.isArray(labels)) {1164 if (!Array.isArray(labels)) {
1321 return [];1165 return [];
1322 }1166 }
13231167
1324 for (const item of labels.sort()) {1168 for (const expression of labels.sort()) {
1325 const sprite = sprites.find(x => x.label == item);1169 const isCustom = extension_settings.expressions.custom?.includes(expression);
1326 const isCustom = extension_settings.expressions.custom.includes(item);1170 const images = sprites
13271171 .filter(s => s.label === expression)
1328 if (sprite) {1172 .map(s => s.files)
1329 validExpressions.push(sprite);1173 .flat();
1330 const listItem = await getListItem(item, sprite.path, 'success', isCustom);1174
1175 if (images.length === 0) {
1176 const listItem = await getListItem(expression, {
1177 isCustom,
1178 images: [getPlaceholderImage(expression, isCustom)],
1179 });
1331 $('#image_list').append(listItem);1180 $('#image_list').append(listItem);
1181 continue;
1332 }1182 }
1333 else {1183
1334 const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom);1184 validExpressions.push({ label: expression, files: images });
1185
1186 // Render main = first file, additional = rest
1187 let listItem = await getListItem(expression, {
1188 isCustom,
1189 images,
1190 });
1335 $('#image_list').append(listItem);1191 $('#image_list').append(listItem);
1336 }1192 }
1337 }
1338 return validExpressions;1193 return validExpressions;
1339}1194}
13401195
1341/**1196/**
1342 * Renders a list item template for the expressions list.1197 * Renders a list item template for the expressions list.
1343 * @param {string} item Expression name1198 * @param {string} expression Expression name
1344 * @param {string} imageSrc Path to image1199 * @param {object} args Arguments object
1345 * @param {'success' | 'failure'} textClass 'success' or 'failure'1200 * @param {ExpressionImage[]} [args.images] Array of image objects
1346 * @param {boolean} isCustom If expression is added by user1201 * @param {boolean} [args.isCustom=false] If expression is added by user
1347 * @returns {Promise<string>} Rendered list item template1202 * @returns {Promise<string>} Rendered list item template
1348 */1203 */
1349async function getListItem(item, imageSrc, textClass, isCustom) {1204async function getListItem(expression, { images, isCustom = false } = {}) {
1350 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom });1205 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false });
1351}1206}
13521207
1208/**
1209 * Fetches and processes the list of sprites for a given character name.
1210 * Retrieves sprite data from the server and organizes it into labeled groups.
1211 *
1212 * @param {string} name - The character name to fetch sprites for
1213 * @returns {Promise<Expression[]>} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data
1214 */
1215
1353async function getSpritesList(name) {1216async function getSpritesList(name) {
1354 console.debug('getting sprites list');1217 console.debug('getting sprites list');
13551218
1356 try {1219 try {
1357 const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);1220 const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
1221 /** @type {{ label: string, path: string }[]} */
1358 let sprites = result.ok ? (await result.json()) : [];1222 let sprites = result.ok ? (await result.json()) : [];
1359 return sprites;1223
1224 /** @type {Expression[]} */
1225 const grouped = sprites.reduce((acc, sprite) => {
1226 const imageData = getExpressionImageData(sprite);
1227 let existingExpression = acc.find(exp => exp.label === sprite.label);
1228 if (existingExpression) {
1229 existingExpression.files.push(imageData);
1230 } else {
1231 acc.push({ label: sprite.label, files: [imageData] });
1232 }
1233
1234 return acc;
1235 }, []);
1236
1237 // Sort the sprites for each expression alphabetically, but keep the main expression file at the front
1238 for (const expression of grouped) {
1239 expression.files.sort((a, b) => {
1240 if (a.title === expression.label) return -1;
1241 if (b.title === expression.label) return 1;
1242 return a.title.localeCompare(b.title);
1243 });
1244
1245 // Mark all besides the first sprite as 'additional'
1246 for (let i = 1; i < expression.files.length; i++) {
1247 expression.files[i].type = 'additional';
1248 }
1249 }
1250
1251 return grouped;
1360 }1252 }
1361 catch (err) {1253 catch (err) {
1362 console.log(err);1254 console.log(err);
@@ -1395,17 +1287,31 @@ async function renderFallbackExpressionPicker() {
1395 const defaultPicker = $('#expression_fallback');1287 const defaultPicker = $('#expression_fallback');
1396 defaultPicker.empty();1288 defaultPicker.empty();
13971289
1398 const fallbackExpression = getFallbackExpression();1290
1291 addOption(OPTION_NO_FALLBACK, '[ No fallback ]', !extension_settings.expressions.fallback_expression);
1292 addOption(OPTION_EMOJI_FALLBACK, '[ Default emojis ]', !!extension_settings.expressions.showDefault);
13991293
1400 for (const expression of expressions) {1294 for (const expression of expressions) {
1295 addOption(expression, expression, expression == extension_settings.expressions.fallback_expression);
1296 }
1297
1298 /** @type {(value: string, label: string, isSelected: boolean) => void} */
1299 function addOption(value, label, isSelected) {
1401 const option = document.createElement('option');1300 const option = document.createElement('option');
1402 option.value = expression;1301 option.value = value;
1403 option.text = expression;1302 option.text = label;
1404 option.selected = expression == fallbackExpression;1303 option.selected = isSelected;
1405 defaultPicker.append(option);1304 defaultPicker.append(option);
1406 }1305 }
1407}1306}
14081307
1308/**
1309 * Retrieves a unique list of cached expressions.
1310 * Combines the default expressions list with custom user-defined expressions.
1311 *
1312 * @returns {string[]} An array of unique expression labels
1313 */
1314
1409function getCachedExpressions() {1315function getCachedExpressions() {
1410 if (!Array.isArray(expressionsList)) {1316 if (!Array.isArray(expressionsList)) {
1411 return [];1317 return [];
@@ -1463,7 +1369,7 @@ export async function getExpressionsList() {
1463 }1369 }
14641370
1465 // If there was no specific list, or an error, just return the default expressions1371 // If there was no specific list, or an error, just return the default expressions
1466 expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice();1372 expressionsList = DEFAULT_EXPRESSIONS.slice();
1467 return expressionsList;1373 return expressionsList;
1468 }1374 }
14691375
@@ -1471,38 +1377,88 @@ export async function getExpressionsList() {
1471 return [...result, ...extension_settings.expressions.custom].filter(onlyUnique);1377 return [...result, ...extension_settings.expressions.custom].filter(onlyUnique);
1472}1378}
14731379
1474async function setExpression(character, expression, force) {1380/**
1475 if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {1381 * Selects a sprite from the given sprite folder for the given expression.
1476 console.debug('entered setExpressions');1382 *
1477 await validateImages(character);1383 * If multiple sprites are allowed for the expression, it will randomly select one.
1384 * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same.
1385 * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one.
1386 *
1387 * @param {string} spriteFolderName - The name of the sprite folder
1388 * @param {string} expression - The expression to find the sprite for
1389 * @param {object} [options] - Options to select the sprite
1390 * @param {string} [options.prevExpressionSrc=null] - The source of the previous expression
1391 * @param {string} [options.overrideSpriteFile=null] - The file name of the sprite to select
1392 * @returns {ExpressionImage?} - The selected sprite
1393 */
1394function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) {
1395 if (!spriteCache[spriteFolderName]) return null;
1396 if (expression === RESET_SPRITE_LABEL) return null;
1397
1398 // Search for sprites of that expression - or fallback expression sprites if enabled
1399 let sprite = spriteCache[spriteFolderName].find(x => x.label === expression);
1400 if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) {
1401 sprite = spriteCache[spriteFolderName].find(x => x.label === extension_settings.expressions.fallback_expression);
1402 console.debug('Expression', expression, 'not found. Using fallback expression', extension_settings.expressions.fallback_expression);
1403 }
1404 if (!(sprite?.files.length > 0)) return null;
1405
1406 let spriteFile = sprite.files[0];
1407
1408 // If a specific sprite file should be set, we are looking it up here
1409 if (overrideSpriteFile) {
1410 const searched = sprite.files.find(x => x.fileName === overrideSpriteFile);
1411 if (searched) spriteFile = searched;
1412 else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`);
1413 }
1414 // Else calculate next expression, if multiple are allowed
1415 else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
1416 let possibleFiles = sprite.files;
1417 if (extension_settings.expressions.rerollIfSame) {
1418 possibleFiles = possibleFiles.filter(x => !prevExpressionSrc || x.imageSrc !== prevExpressionSrc);
1419 }
1420 spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)];
1421 }
1422
1423 return spriteFile;
1424
1425}
1426
1427/**
1428 * Set the expression of a character.
1429 * @param {string} spriteFolderName - The name of the character (folder name - can also be a costume override)
1430 * @param {string} expression - The expression or sprite name to set
1431 * @param {Object} options - Optional parameters
1432 * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on
1433 * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
1434 * @returns {Promise<void>} A promise that resolves when the expression has been set.
1435 */
1436async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) {
1437 await validateImages(spriteFolderName);
1478 const img = $('img.expression');1438 const img = $('img.expression');
1479 const prevExpressionSrc = img.attr('src');1439 const prevExpressionSrc = img.attr('src');
1480 const expressionClone = img.clone();1440 const expressionClone = img.clone();
14811441
1482 const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));1442 const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile });
1483 console.debug('checking for expression images to show..');1443 if (spriteFile) {
1484 if (sprite) {
1485 console.debug('setting expression from character images folder');
1486
1487 if (force && isVisualNovelMode()) {1444 if (force && isVisualNovelMode()) {
1488 const context = getContext();1445 const context = getContext();
1489 const group = context.groups.find(x => x.id === context.groupId);1446 const group = context.groups.find(x => x.id === context.groupId);
14901447
1491 for (const member of group.members) {1448 // If it's a folder, make sure we find the group member based on the actual name
1492 const groupMember = context.characters.find(x => x.avatar === member);1449 const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName;
1493
1494 if (!groupMember) {
1495 continue;
1496 }
14971450
1498 if (groupMember.name == character) {1451 const groupMember = group.members
1499 await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);1452 .map(member => context.characters.find(x => x.avatar === member))
1453 .find(groupMember => groupMember && groupMember.name === memberName);
1454 if (groupMember) {
1455 await setImage($(`.expression-holder[data-avatar="${groupMember.avatar}"] img`), spriteFile.imageSrc);
1500 return;1456 return;
1501 }1457 }
1502 }1458 }
1503 }1459
1504 //only swap expressions when necessary1460 //only swap expressions when necessary
1505 if (prevExpressionSrc !== sprite.path1461 if (prevExpressionSrc !== spriteFile.imageSrc
1506 && !img.hasClass('expression-animating')) {1462 && !img.hasClass('expression-animating')) {
1507 //clone expression1463 //clone expression
1508 expressionClone.addClass('expression-clone');1464 expressionClone.addClass('expression-clone');
@@ -1510,7 +1466,12 @@ async function setExpression(character, expression, force) {
1510 //must be made invisible to start because they share the same Z-index1466 //must be made invisible to start because they share the same Z-index
1511 expressionClone.attr('id', '').css({ opacity: 0 });1467 expressionClone.attr('id', '').css({ opacity: 0 });
1512 //add new sprite path to clone src1468 //add new sprite path to clone src
1513 expressionClone.attr('src', sprite.path);1469 expressionClone.attr('src', spriteFile.imageSrc);
1470 //set relevant data tags
1471 expressionClone.attr('data-sprite-folder-name', spriteFolderName);
1472 expressionClone.attr('data-expression', expression);
1473 expressionClone.attr('data-sprite-filename', spriteFile.fileName);
1474 expressionClone.attr('title', expression);
1514 //add invisible clone to html1475 //add invisible clone to html
1515 expressionClone.appendTo($('#expression-holder'));1476 expressionClone.appendTo($('#expression-holder'));
15161477
@@ -1552,80 +1513,85 @@ async function setExpression(character, expression, force) {
1552 expressionHolder.css('min-height', 100);1513 expressionHolder.css('min-height', 100);
1553 });1514 });
15541515
1555
1556 expressionClone.removeClass('expression-clone');1516 expressionClone.removeClass('expression-clone');
15571517
1558 expressionClone.removeClass('default');1518 expressionClone.removeClass('default');
1559 expressionClone.off('error');1519 expressionClone.off('error');
1560 expressionClone.on('error', function () {1520 expressionClone.on('error', function (error) {
1561 console.debug('Expression image error', sprite.path);1521 console.debug('Expression image error', spriteFile.imageSrc, error);
1562 $(this).attr('src', '');1522 $(this).attr('src', '');
1563 $(this).off('error');1523 $(this).off('error');
1564 if (force && extension_settings.expressions.showDefault) {1524 if (force && extension_settings.expressions.showDefault) {
1565 setDefault();1525 setDefaultEmojiForImage(img, expression);
1566 }1526 }
1567 });1527 });
1568 }1528 }
1529
1530 console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
1569 }1531 }
1570 else {1532 else {
1571 if (extension_settings.expressions.showDefault) {1533 img.attr('data-sprite-folder-name', spriteFolderName);
1572 setDefault();
1573 }
1574 }
15751534
1576 function setDefault() {1535 img.off('error');
1577 console.debug('setting default');
1578 const defImgUrl = `/img/default-expressions/${expression}.png`;
1579 //console.log(defImgUrl);
1580 img.attr('src', defImgUrl);
1581 img.addClass('default');
1582 }
1583 document.getElementById('expression-holder').style.display = '';
15841536
1537 if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) {
1538 setDefaultEmojiForImage(img, expression);
1585 } else {1539 } else {
1586 // Set the Talkinghead emotion to the specified expression1540 setNoneForImage(img, expression);
1587 // TODO: For now, Talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.
1588 try {
1589 let result = await isTalkingHeadAvailable();
1590 if (result) {
1591 const url = new URL(getApiUrl());
1592 url.pathname = '/api/talkinghead/set_emotion';
1593 await doExtrasFetch(url, {
1594 method: 'POST',
1595 headers: {
1596 'Content-Type': 'application/json',
1597 },
1598 body: JSON.stringify({ emotion_name: expression }),
1599 });
1600 }
1601 }1541 }
1602 catch (error) {1542 console.debug('Expression unset - No sprite found', { expression: expression });
1603 // `set_emotion` is not present in old versions, so let it 404.
1604 }1543 }
16051544
1606 try {1545 document.getElementById('expression-holder').style.display = '';
1607 // Find the <img> element with id="expression-image" and class="expression"
1608 const imgElement = document.querySelector('img#expression-image.expression');
1609 //console.log("searching");
1610 if (imgElement && imgElement instanceof HTMLImageElement) {
1611 //console.log("setting value");
1612 imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
1613 }
1614}1546}
1615 catch (error) {1547
1616 //console.log("The fetch failed!");1548/**
1549 * Sets the default expression image for the given image element and expression
1550 * @param {JQuery<HTMLElement>} img - The image element to set the default expression for
1551 * @param {string} expression - The expression label to use for the default image
1552 */
1553function setDefaultEmojiForImage(img, expression) {
1554 if (extension_settings.expressions.custom?.includes(expression)) {
1555 console.debug(`Can't set default emoji for a custom expression (${expression}). setting to ${DEFAULT_FALLBACK_EXPRESSION} instead.`);
1556 expression = DEFAULT_FALLBACK_EXPRESSION;
1617 }1557 }
1558
1559 const defImgUrl = `/img/default-expressions/${expression}.png`;
1560 img.attr('src', defImgUrl);
1561 img.attr('data-expression', expression);
1562 img.attr('data-sprite-filename', null);
1563 img.attr('title', expression);
1564 img.addClass('default');
1618}1565}
1566
1567/**
1568 * Sets the image element to display no expression by clearing its source attribute.
1569 * @param {JQuery<HTMLElement>} img - The image element to clear the expression for
1570 * @param {string} expression - The expression label to use
1571 */
1572function setNoneForImage(img, expression) {
1573 img.attr('src', '');
1574 img.attr('data-expression', expression);
1575 img.attr('data-sprite-filename', null);
1576 img.attr('title', expression);
1577 img.removeClass('default');
1619}1578}
16201579
1621function onClickExpressionImage() {1580function onClickExpressionImage() {
1622 const expression = $(this).attr('id');1581 // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label
1623 setSpriteSlashCommand({}, expression);1582 if ($(this).attr('data-expression-type') === 'failure') {
1583 const label = $(this).attr('data-expression');
1584 setSpriteSlashCommand({ type: 'expression' }, label);
1585 return;
1586 }
1587
1588 const spriteFile = $(this).attr('data-filename');
1589 setSpriteSlashCommand({ type: 'sprite' }, spriteFile);
1624}1590}
16251591
1626async function onClickExpressionAddCustom() {1592async function onClickExpressionAddCustom() {
1627 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');1593 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');
1628 let expressionName = await callPopup(template, 'input');1594 let expressionName = await Popup.show.input(null, template);
16291595
1630 if (!expressionName) {1596 if (!expressionName) {
1631 console.debug('No custom expression name provided');1597 console.debug('No custom expression name provided');
@@ -1636,19 +1602,15 @@ async function onClickExpressionAddCustom() {
16361602
1637 // a-z, 0-9, dashes and underscores only1603 // a-z, 0-9, dashes and underscores only
1638 if (!/^[a-z0-9-_]+$/.test(expressionName)) {1604 if (!/^[a-z0-9-_]+$/.test(expressionName)) {
1639 toastr.info('Invalid custom expression name provided');1605 toastr.warning('Invalid custom expression name provided', 'Add Custom Expression');
1640 return;1606 return;
1641 }1607 }
16421608 if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) {
1643 // Check if expression name already exists in default expressions1609 toastr.warning('Expression name already exists', 'Add Custom Expression');
1644 if (DEFAULT_EXPRESSIONS.includes(expressionName)) {
1645 toastr.info('Expression name already exists');
1646 return;1610 return;
1647 }1611 }
1648
1649 // Check if expression name already exists in custom expressions
1650 if (extension_settings.expressions.custom.includes(expressionName)) {1612 if (extension_settings.expressions.custom.includes(expressionName)) {
1651 toastr.info('Custom expression already exists');1613 toastr.warning('Custom expression already exists', 'Add Custom Expression');
1652 return;1614 return;
1653 }1615 }
16541616
@@ -1665,14 +1627,15 @@ async function onClickExpressionAddCustom() {
16651627
1666async function onClickExpressionRemoveCustom() {1628async function onClickExpressionRemoveCustom() {
1667 const selectedExpression = String($('#expression_custom').val());1629 const selectedExpression = String($('#expression_custom').val());
1630 const noCustomExpressions = extension_settings.expressions.custom.length === 0;
16681631
1669 if (!selectedExpression) {1632 if (!selectedExpression || noCustomExpressions) {
1670 console.debug('No custom expression selected');1633 console.debug('No custom expression selected');
1671 return;1634 return;
1672 }1635 }
16731636
1674 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });1637 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });
1675 const confirmation = await callPopup(template, 'confirm');1638 const confirmation = await Popup.show.confirm(null, template);
16761639
1677 if (!confirmation) {1640 if (!confirmation) {
1678 console.debug('Custom expression removal cancelled');1641 console.debug('Custom expression removal cancelled');
@@ -1682,8 +1645,8 @@ async function onClickExpressionRemoveCustom() {
1682 // Remove custom expression from settings1645 // Remove custom expression from settings
1683 const index = extension_settings.expressions.custom.indexOf(selectedExpression);1646 const index = extension_settings.expressions.custom.indexOf(selectedExpression);
1684 extension_settings.expressions.custom.splice(index, 1);1647 extension_settings.expressions.custom.splice(index, 1);
1685 if (selectedExpression == getFallbackExpression()) {1648 if (selectedExpression == extension_settings.expressions.fallback_expression) {
1686 toastr.warning(`Deleted custom expression '${selectedExpression}' that was also selected as the fallback expression.\nFallback expression has been reset to '${DEFAULT_FALLBACK_EXPRESSION}'.`);1649 toastr.warning(`Deleted custom expression '${selectedExpression}' that was also selected as the fallback expression.\nFallback expression has been reset to '${DEFAULT_FALLBACK_EXPRESSION}'.`, 'Remove Custom Expression');
1687 extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;1650 extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;
1688 }1651 }
1689 await renderAdditionalExpressionSettings();1652 await renderAdditionalExpressionSettings();
@@ -1707,12 +1670,35 @@ function onExpressionApiChanged() {
1707 }1670 }
1708}1671}
17091672
1710function onExpressionFallbackChanged() {1673async function onExpressionFallbackChanged() {
1711 const expression = this.value;1674 /** @type {HTMLSelectElement} */
1712 if (expression) {1675 const select = this;
1713 extension_settings.expressions.fallback_expression = expression;1676 const selectedValue = select.value;
1714 saveSettingsDebounced();1677
1678 switch (selectedValue) {
1679 case OPTION_NO_FALLBACK:
1680 extension_settings.expressions.fallback_expression = null;
1681 extension_settings.expressions.showDefault = false;
1682 break;
1683 case OPTION_EMOJI_FALLBACK:
1684 extension_settings.expressions.fallback_expression = null;
1685 extension_settings.expressions.showDefault = true;
1686 break;
1687 default:
1688 extension_settings.expressions.fallback_expression = selectedValue;
1689 extension_settings.expressions.showDefault = false;
1690 break;
1691 }
1692
1693 const img = $('img.expression');
1694 const spriteFolderName = img.attr('data-sprite-folder-name');
1695 const expression = img.attr('data-expression');
1696
1697 if (spriteFolderName && expression) {
1698 await sendExpressionCall(spriteFolderName, expression, { force: true });
1715 }1699 }
1700
1701 saveSettingsDebounced();
1716}1702}
17171703
1718async function handleFileUpload(url, formData) {1704async function handleFileUpload(url, formData) {
@@ -1739,34 +1725,111 @@ async function handleFileUpload(url, formData) {
1739 }1725 }
1740}1726}
17411727
1728/**
1729 * Removes the file extension from a file name
1730 * @param {string} fileName The file name to remove the extension from
1731 * @returns {string} The file name without the extension
1732 */
1733function withoutExtension(fileName) {
1734 return fileName.replace(/\.[^/.]+$/, '');
1735}
1736
1737function validateExpressionSpriteName(expression, spriteName) {
1738 const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1739 const validFileName = filenameValidationRegex.test(spriteName);
1740 return validFileName;
1741}
1742
1742async function onClickExpressionUpload(event) {1743async function onClickExpressionUpload(event) {
1743 // Prevents the expression from being set1744 // Prevents the expression from being set
1744 event.stopPropagation();1745 event.stopPropagation();
17451746
1746 const id = $(this).closest('.expression_list_item').attr('id');1747 const expressionListItem = $(this).closest('.expression_list_item');
1748
1749 const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null;
1750 const expression = expressionListItem.data('expression');
1747 const name = $('#image_list').data('name');1751 const name = $('#image_list').data('name');
17481752
1749 const handleExpressionUploadChange = async (e) => {1753 const handleExpressionUploadChange = async (e) => {
1750 const file = e.target.files[0];1754 const file = e.target.files[0];
17511755
1752 if (!file) {1756 if (!file || !file.name) {
1757 console.debug('No valid file selected');
1758 return;
1759 }
1760
1761 const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || [];
1762
1763 let spriteName = expression;
1764
1765 if (extension_settings.expressions.allowMultiple) {
1766 const matchesExisting = existingFiles.some(x => x.fileName === file.name);
1767 const fileNameWithoutExtension = withoutExtension(file.name);
1768 const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
1769
1770 // If there is no expression yet and it's a valid expression, we just take it
1771 if (!clickedFileName && validFileName) {
1772 spriteName = fileNameWithoutExtension;
1773 }
1774 // If the filename matches the one that was clicked, we just take it and replace it
1775 else if (clickedFileName === file.name) {
1776 spriteName = fileNameWithoutExtension;
1777 }
1778 // If it's a valid filename and there's no existing file with the same name, we just take it
1779 else if (!matchesExisting && validFileName) {
1780 spriteName = fileNameWithoutExtension;
1781 }
1782 else {
1783 /** @type {import('../../popup.js').CustomPopupButton[]} */
1784 const customButtons = [];
1785 if (clickedFileName) {
1786 customButtons.push({
1787 text: t`Replace Existing`,
1788 result: POPUP_RESULT.NEGATIVE,
1789 action: () => {
1790 console.debug('Replacing existing sprite');
1791 spriteName = withoutExtension(clickedFileName);
1792 },
1793 });
1794 }
1795
1796 spriteName = null;
1797 const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles);
1798
1799 const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
1800
1801 const input = await Popup.show.input(t`Upload Expression Sprite`, message,
1802 suggestedSpriteName, { customButtons: customButtons });
1803
1804 if (input) {
1805 if (!validateExpressionSpriteName(expression, input)) {
1806 toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
1807 return;
1808 }
1809 spriteName = input;
1810 }
1811 }
1812 } else {
1813 spriteName = withoutExtension(clickedFileName);
1814 }
1815
1816 if (!spriteName) {
1817 toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`);
1818 // Reset the input
1819 e.target.form.reset();
1753 return;1820 return;
1754 }1821 }
17551822
1756 const formData = new FormData();1823 const formData = new FormData();
1757 formData.append('name', name);1824 formData.append('name', name);
1758 formData.append('label', id);1825 formData.append('label', expression);
1759 formData.append('avatar', file);1826 formData.append('avatar', file);
1827 formData.append('spriteName', spriteName);
17601828
1761 await handleFileUpload('/api/sprites/upload', formData);1829 await handleFileUpload('/api/sprites/upload', formData);
17621830
1763 // Reset the input1831 // Reset the input
1764 e.target.form.reset();1832 e.target.form.reset();
1765
1766 // In Talkinghead mode, when a new talkinghead image is uploaded, refresh the live char.
1767 if (id === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1768 await loadTalkingHead();
1769 }
1770 };1833 };
17711834
1772 $('#expression_upload')1835 $('#expression_upload')
@@ -1822,8 +1885,9 @@ async function onClickExpressionOverrideButton() {
1822 inApiCall = true;1885 inApiCall = true;
1823 $('#visual-novel-wrapper').empty();1886 $('#visual-novel-wrapper').empty();
1824 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);1887 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
1888 const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
1825 const expression = await getExpressionLabel(currentLastMessage.mes);1889 const expression = await getExpressionLabel(currentLastMessage.mes);
1826 await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true);1890 await sendExpressionCall(name, expression, { force: true });
1827 forceUpdateVisualNovelMode();1891 forceUpdateVisualNovelMode();
1828 } catch (error) {1892 } catch (error) {
1829 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);1893 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
@@ -1849,7 +1913,7 @@ async function onClickExpressionOverrideRemoveAllButton() {
1849 const currentLastMessage = getLastCharacterMessage();1913 const currentLastMessage = getLastCharacterMessage();
1850 await validateImages(currentLastMessage.name, true);1914 await validateImages(currentLastMessage.name, true);
1851 const expression = await getExpressionLabel(currentLastMessage.mes);1915 const expression = await getExpressionLabel(currentLastMessage.mes);
1852 await sendExpressionCall(currentLastMessage.name, expression, true);1916 await sendExpressionCall(currentLastMessage.name, expression, { force: true });
1853 forceUpdateVisualNovelMode();1917 forceUpdateVisualNovelMode();
18541918
1855 console.debug(extension_settings.expressionOverrides);1919 console.debug(extension_settings.expressionOverrides);
@@ -1872,16 +1936,13 @@ async function onClickExpressionUploadPackButton() {
1872 formData.append('name', name);1936 formData.append('name', name);
1873 formData.append('avatar', file);1937 formData.append('avatar', file);
18741938
1939 const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 });
1875 const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);1940 const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
1941 toastr.clear(uploadToast);
1876 toastr.success(`Uploaded ${count} image(s) for ${name}`);1942 toastr.success(`Uploaded ${count} image(s) for ${name}`);
18771943
1878 // Reset the input1944 // Reset the input
1879 e.target.form.reset();1945 e.target.form.reset();
1880
1881 // In Talkinghead mode, refresh the live char.
1882 if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1883 await loadTalkingHead();
1884 }
1885 };1946 };
18861947
1887 $('#expression_upload_pack')1948 $('#expression_upload_pack')
@@ -1894,20 +1955,28 @@ async function onClickExpressionDelete(event) {
1894 // Prevents the expression from being set1955 // Prevents the expression from being set
1895 event.stopPropagation();1956 event.stopPropagation();
18961957
1897 const confirmation = await callPopup('<h3>Are you sure?</h3>Once deleted, it\'s gone forever!', 'confirm');1958 const expressionListItem = $(this).closest('.expression_list_item');
1959 const expression = expressionListItem.data('expression');
1960
1961 if (expressionListItem.attr('data-expression-type') === 'failure') {
1962 return;
1963 }
18981964
1965 const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!`
1966 + '<br /><br />'
1967 + t`Expression:` + ' <tt>' + expressionListItem.attr('data-filename') + '</tt>');
1899 if (!confirmation) {1968 if (!confirmation) {
1900 return;1969 return;
1901 }1970 }
19021971
1903 const id = $(this).closest('.expression_list_item').attr('id');1972 const fileName = withoutExtension(expressionListItem.attr('data-filename'));
1904 const name = $('#image_list').data('name');1973 const name = $('#image_list').data('name');
19051974
1906 try {1975 try {
1907 await fetch('/api/sprites/delete', {1976 await fetch('/api/sprites/delete', {
1908 method: 'POST',1977 method: 'POST',
1909 headers: getRequestHeaders(),1978 headers: getRequestHeaders(),
1910 body: JSON.stringify({ name, label: id }),1979 body: JSON.stringify({ name, label: expression, spriteName: fileName }),
1911 });1980 });
1912 } catch (error) {1981 } catch (error) {
1913 toastr.error('Failed to delete image. Try again later.');1982 toastr.error('Failed to delete image. Try again later.');
@@ -1984,6 +2053,16 @@ function migrateSettings() {
1984 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;2053 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
1985 saveSettingsDebounced();2054 saveSettingsDebounced();
1986 }2055 }
2056
2057 if (extension_settings.expressions.allowMultiple === undefined) {
2058 extension_settings.expressions.allowMultiple = true;
2059 saveSettingsDebounced();
2060 }
2061
2062 if (extension_settings.expressions.showDefault && extension_settings.expressions.fallback_expression !== undefined) {
2063 extension_settings.expressions.showDefault = false;
2064 saveSettingsDebounced();
2065 }
1987}2066}
19882067
1989(async function () {2068(async function () {
@@ -2010,13 +2089,19 @@ function migrateSettings() {
2010 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');2089 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
2011 $('#expressions_container').append(template);2090 $('#expressions_container').append(template);
2012 $('#expression_override_button').on('click', onClickExpressionOverrideButton);2091 $('#expression_override_button').on('click', onClickExpressionOverrideButton);
2013 $('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
2014 $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);2092 $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
2015 $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
2016 $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {2093 $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {
2017 extension_settings.expressions.translate = !!$(this).prop('checked');2094 extension_settings.expressions.translate = !!$(this).prop('checked');
2018 saveSettingsDebounced();2095 saveSettingsDebounced();
2019 });2096 });
2097 $('#expressions_allow_multiple').prop('checked', extension_settings.expressions.allowMultiple).on('input', function () {
2098 extension_settings.expressions.allowMultiple = !!$(this).prop('checked');
2099 saveSettingsDebounced();
2100 });
2101 $('#expressions_reroll_if_same').prop('checked', extension_settings.expressions.rerollIfSame).on('input', function () {
2102 extension_settings.expressions.rerollIfSame = !!$(this).prop('checked');
2103 saveSettingsDebounced();
2104 });
2020 $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);2105 $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
2021 $(document).on('dragstart', '.expression', (e) => {2106 $(document).on('dragstart', '.expression', (e) => {
2022 e.preventDefault();2107 e.preventDefault();
@@ -2025,21 +2110,15 @@ function migrateSettings() {
2025 $(document).on('click', '.expression_list_item', onClickExpressionImage);2110 $(document).on('click', '.expression_list_item', onClickExpressionImage);
2026 $(document).on('click', '.expression_list_upload', onClickExpressionUpload);2111 $(document).on('click', '.expression_list_upload', onClickExpressionUpload);
2027 $(document).on('click', '.expression_list_delete', onClickExpressionDelete);2112 $(document).on('click', '.expression_list_delete', onClickExpressionDelete);
2028 $(window).on('resize', updateVisualNovelModeDebounced);2113 $(window).on('resize', () => updateVisualNovelModeDebounced());
2029 $('#open_chat_expressions').hide();2114 $('#open_chat_expressions').hide();
20302115
2031 $('#image_type_toggle').on('click', function () {
2032 if (this instanceof HTMLInputElement) {
2033 setTalkingHeadState(this.checked);
2034 }
2035 });
2036
2037 await renderAdditionalExpressionSettings();2116 await renderAdditionalExpressionSettings();
2038 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);2117 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);
2039 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));2118 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
2040 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');2119 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');
2041 $('#expression_llm_prompt').on('input', function () {2120 $('#expression_llm_prompt').on('input', function () {
2042 extension_settings.expressions.llmPrompt = $(this).val();2121 extension_settings.expressions.llmPrompt = String($(this).val());
2043 saveSettingsDebounced();2122 saveSettingsDebounced();
2044 });2123 });
2045 $('#expression_llm_prompt_restore').on('click', function () {2124 $('#expression_llm_prompt_restore').on('click', function () {
@@ -2054,34 +2133,6 @@ function migrateSettings() {
2054 $('#expression_api').on('change', onExpressionApiChanged);2133 $('#expression_api').on('change', onExpressionApiChanged);
2055 }2134 }
20562135
2057 // Pause Talkinghead to save resources when the ST tab is not visible or the window is minimized.
2058 // We currently do this via loading/unloading. Could be improved by adding new pause/unpause endpoints to Extras.
2059 document.addEventListener('visibilitychange', function (event) {
2060 let pageIsVisible;
2061 if (document.hidden) {
2062 console.debug('expressions: SillyTavern is now hidden');
2063 pageIsVisible = false;
2064 } else {
2065 console.debug('expressions: SillyTavern is now visible');
2066 pageIsVisible = true;
2067 }
2068
2069 if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
2070 isTalkingHeadAvailable().then(result => {
2071 if (result) {
2072 if (pageIsVisible) {
2073 loadTalkingHead();
2074 } else {
2075 unloadTalkingHead();
2076 }
2077 handleImageChange(); // Change image as needed
2078 } else {
2079 //console.log("talkinghead does not exist.");
2080 }
2081 });
2082 }
2083 });
2084
2085 addExpressionImage();2136 addExpressionImage();
2086 addVisualNovelMode();2137 addVisualNovelMode();
2087 migrateSettings();2138 migrateSettings();
@@ -2090,11 +2141,6 @@ function migrateSettings() {
2090 const updateFunction = wrapper.update.bind(wrapper);2141 const updateFunction = wrapper.update.bind(wrapper);
2091 setInterval(updateFunction, UPDATE_INTERVAL);2142 setInterval(updateFunction, UPDATE_INTERVAL);
2092 moduleWorker();2143 moduleWorker();
2093 // For setting the Talkinghead talking animation on/off quickly enough for realtime use, we need another timer on a shorter schedule.
2094 const wrapperTalkingState = new ModuleWorkerWrapper(updateTalkingState);
2095 const updateTalkingStateFunction = wrapperTalkingState.update.bind(wrapperTalkingState);
2096 setInterval(updateTalkingStateFunction, TALKINGCHECK_UPDATE_INTERVAL);
2097 updateTalkingState();
2098 dragElement($('#expression-holder'));2144 dragElement($('#expression-holder'));
2099 eventSource.on(event_types.CHAT_CHANGED, () => {2145 eventSource.on(event_types.CHAT_CHANGED, () => {
2100 // character changed2146 // character changed
@@ -2108,110 +2154,137 @@ function migrateSettings() {
2108 imgElement.src = '';2154 imgElement.src = '';
2109 }2155 }
21102156
2111 //set checkbox to global var
2112 $('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead);
2113 if (extension_settings.expressions.talkinghead) {
2114 setTalkingHeadState(extension_settings.expressions.talkinghead);
2115 }
2116
2117 setExpressionOverrideHtml();2157 setExpressionOverrideHtml();
21182158
2119 if (isVisualNovelMode()) {2159 if (isVisualNovelMode()) {
2120 $('#visual-novel-wrapper').empty();2160 $('#visual-novel-wrapper').empty();
2121 }2161 }
21222162
2123 updateFunction();2163 updateFunction({ newChat: true });
2124 });2164 });
2125 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);2165 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
2126 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);2166 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
2127 eventSource.on(event_types.EXTRAS_CONNECTED, () => {
2128 if (extension_settings.expressions.talkinghead) {
2129 setTalkingHeadState(extension_settings.expressions.talkinghead);
2130 }
2131 });
21322167
2133 const localEnumProviders = {2168 const localEnumProviders = {
2134 expressions: () => getCachedExpressions().map(expression => {2169 expressions: () => {
2170 const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2171 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2172 const expressions = getCachedExpressions();
2173 return expressions.map(expression => {
2174 const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
2135 const isCustom = extension_settings.expressions.custom?.includes(expression);2175 const isCustom = extension_settings.expressions.custom?.includes(expression);
2136 return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D');2176 const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
2137 }),2177 spriteCount > 1 ? `${spriteCount} sprites` : null;
2178 return new SlashCommandEnumValue(expression,
2179 subtitle,
2180 isCustom ? enumTypes.name : enumTypes.enum,
2181 isCustom ? 'C' : 'D');
2182 });
2183 },
2184 sprites: () => {
2185 const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2186 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2187 const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? [];
2188 return sprites.map(x => {
2189 return new SlashCommandEnumValue(x.title,
2190 x.title !== x.expression ? x.expression : null,
2191 x.isCustom ? enumTypes.name : enumTypes.enum,
2192 x.isCustom ? 'C' : 'D');
2193 });
2194 },
2138 };2195 };
21392196
2140 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2197 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2141 name: 'sprite',2198 name: 'expression-set',
2142 aliases: ['emote'],2199 aliases: ['sprite', 'emote'],
2143 callback: setSpriteSlashCommand,2200 callback: setSpriteSlashCommand,
2201 namedArgumentList: [
2202 SlashCommandNamedArgument.fromProps({
2203 name: 'type',
2204 description: 'Whether to set an expression or a specific sprite.',
2205 typeList: [ARGUMENT_TYPE.STRING],
2206 isRequired: false,
2207 defaultValue: 'expression',
2208 enumList: ['expression', 'sprite'],
2209 }),
2210 ],
2144 unnamedArgumentList: [2211 unnamedArgumentList: [
2145 SlashCommandArgument.fromProps({2212 SlashCommandArgument.fromProps({
2146 description: 'spriteId',2213 description: 'expression label to set',
2147 typeList: [ARGUMENT_TYPE.STRING],2214 typeList: [ARGUMENT_TYPE.STRING],
2148 isRequired: true,2215 isRequired: true,
2149 enumProvider: localEnumProviders.expressions,2216 enumProvider: (executor, _) => {
2217 // Check if command is used to set a sprite, then use those enums
2218 const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression';
2219 if (type == 'sprite') return localEnumProviders.sprites();
2220 else return [
2221 ...localEnumProviders.expressions(),
2222 new SlashCommandEnumValue(RESET_SPRITE_LABEL, 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'),
2223 ];
2224 },
2150 }),2225 }),
2151 ],2226 ],
2152 helpString: 'Force sets the sprite for the current character.',2227 helpString: 'Force sets the expression for the current character.',
2153 returns: 'the currently set sprite label after setting it.',2228 returns: 'The currently set expression label after setting it.',
2154 }));2229 }));
2155 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2230 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2156 name: 'spriteoverride',2231 name: 'expression-folder-override',
2157 aliases: ['costume'],2232 aliases: ['spriteoverride', 'costume'],
2158 callback: setSpriteSetCommand,2233 callback: setSpriteFolderCommand,
2159 unnamedArgumentList: [2234 unnamedArgumentList: [
2160 new SlashCommandArgument(2235 new SlashCommandArgument(
2161 'optional folder', [ARGUMENT_TYPE.STRING], false,2236 'optional folder', [ARGUMENT_TYPE.STRING], false,
2162 ),2237 ),
2163 ],2238 ],
2164 helpString: 'Sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.',2239 helpString: `
2240 <div>
2241 Sets an override sprite folder for the current character.<br />
2242 In groups, this will apply to the character who last sent a message.
2243 </div>
2244 <div>
2245 If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.
2246 </div>
2247 `,
2165 }));2248 }));
2166 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2249 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2167 name: 'lastsprite',2250 name: 'expression-last',
2168 callback: (_, name) => {2251 aliases: ['lastsprite'],
2252 /** @type {(args: object, name: string) => Promise<string>} */
2253 callback: async (_, name) => {
2169 if (typeof name !== 'string') throw new Error('name must be a string');2254 if (typeof name !== 'string') throw new Error('name must be a string');
2255 if (!name) {
2256 if (selected_group) {
2257 toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`);
2258 return '';
2259 }
2260 name = characters[this_chid]?.avatar;
2261 }
2262
2170 const char = findChar({ name: name });2263 const char = findChar({ name: name });
2264 if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
2265
2171 const sprite = lastExpression[char?.name ?? name] ?? '';2266 const sprite = lastExpression[char?.name ?? name] ?? '';
2172 return sprite;2267 return sprite;
2173 },2268 },
2174 returns: 'the last set sprite / expression for the named character.',2269 returns: 'the last set expression for the named character.',
2175 unnamedArgumentList: [2270 unnamedArgumentList: [
2176 SlashCommandArgument.fromProps({2271 SlashCommandArgument.fromProps({
2177 description: 'Character name - or unique character identifier (avatar key)',2272 description: 'Character name - or unique character identifier (avatar key). If not provided, the current character for this chat will be used (does not work in group chats)',
2178 typeList: [ARGUMENT_TYPE.STRING],2273 typeList: [ARGUMENT_TYPE.STRING],
2179 isRequired: true,
2180 enumProvider: commonEnumProviders.characters('character'),2274 enumProvider: commonEnumProviders.characters('character'),
2181 }),2275 }),
2182 ],2276 ],
2183 helpString: 'Returns the last set sprite / expression for the named character.',2277 helpString: 'Returns the last set expression for the named character.',
2184 }));
2185 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2186 name: 'th',
2187 callback: toggleTalkingHeadCommand,
2188 aliases: ['talkinghead'],
2189 helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
2190 returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
2191 }));2278 }));
2192 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2279 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2193 name: 'classify-expressions',2280 name: 'expression-list',
2194 aliases: ['expressions'],2281 aliases: ['expressions'],
2282 /** @type {(args: {return: string}) => Promise<string>} */
2195 callback: async (args) => {2283 callback: async (args) => {
2284 let returnType =
2196 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */2285 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2197 // @ts-ignore2286 (args.return);
2198 let returnType = args.return;
2199
2200 // Old legacy return type handling
2201 if (args.format) {
2202 toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning');
2203 const type = String(args?.format).toLowerCase().trim();
2204 switch (type) {
2205 case 'json':
2206 returnType = 'object';
2207 break;
2208 default:
2209 returnType = 'pipe';
2210 break;
2211 }
2212 }
22132287
2214 // Now the actual new return type handling
2215 const list = await getExpressionsList();2288 const list = await getExpressionsList();
22162289
2217 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });2290 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
@@ -2225,22 +2298,13 @@ function migrateSettings() {
2225 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),2298 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
2226 forceEnum: true,2299 forceEnum: true,
2227 }),2300 }),
2228 // TODO remove some day
2229 SlashCommandNamedArgument.fromProps({
2230 name: 'format',
2231 description: '!!! DEPRECATED - use "return" instead !!! The format to return the list in: comma-separated plain text or JSON array. Default is plain text.',
2232 typeList: [ARGUMENT_TYPE.STRING],
2233 enumList: [
2234 new SlashCommandEnumValue('plain', null, enumTypes.enum, ', '),
2235 new SlashCommandEnumValue('json', null, enumTypes.enum, '[]'),
2236 ],
2237 }),
2238 ],2301 ],
2239 returns: 'The comma-separated list of available expressions, including custom expressions.',2302 returns: 'The comma-separated list of available expressions, including custom expressions.',
2240 helpString: 'Returns a list of available expressions, including custom expressions.',2303 helpString: 'Returns a list of available expressions, including custom expressions.',
2241 }));2304 }));
2242 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2305 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2243 name: 'classify',2306 name: 'expression-classify',
2307 aliases: ['classify'],
2244 callback: classifyCallback,2308 callback: classifyCallback,
2245 namedArgumentList: [2309 namedArgumentList: [
2246 SlashCommandNamedArgument.fromProps({2310 SlashCommandNamedArgument.fromProps({
@@ -2279,11 +2343,13 @@ function migrateSettings() {
2279 `,2343 `,
2280 }));2344 }));
2281 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2345 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2282 name: 'uploadsprite',2346 name: 'expression-upload',
2347 aliases: ['uploadsprite'],
2348 /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
2283 callback: async (args, url) => {2349 callback: async (args, url) => {
2284 await uploadSpriteCommand(args, url);2350 return await uploadSpriteCommand(args, url);
2285 return '';
2286 },2351 },
2352 returns: 'the resulting sprite name',
2287 unnamedArgumentList: [2353 unnamedArgumentList: [
2288 SlashCommandArgument.fromProps({2354 SlashCommandArgument.fromProps({
2289 description: 'URL of the image to upload',2355 description: 'URL of the image to upload',
@@ -2297,7 +2363,6 @@ function migrateSettings() {
2297 description: 'Character name or avatar key (default is current character)',2363 description: 'Character name or avatar key (default is current character)',
2298 typeList: [ARGUMENT_TYPE.STRING],2364 typeList: [ARGUMENT_TYPE.STRING],
2299 isRequired: false,2365 isRequired: false,
2300 acceptsMultiple: false,
2301 }),2366 }),
2302 SlashCommandNamedArgument.fromProps({2367 SlashCommandNamedArgument.fromProps({
2303 name: 'label',2368 name: 'label',
@@ -2305,16 +2370,32 @@ function migrateSettings() {
2305 typeList: [ARGUMENT_TYPE.STRING],2370 typeList: [ARGUMENT_TYPE.STRING],
2306 enumProvider: localEnumProviders.expressions,2371 enumProvider: localEnumProviders.expressions,
2307 isRequired: true,2372 isRequired: true,
2308 acceptsMultiple: false,
2309 }),2373 }),
2310 SlashCommandNamedArgument.fromProps({2374 SlashCommandNamedArgument.fromProps({
2311 name: 'folder',2375 name: 'folder',
2312 description: 'Override folder to upload into',2376 description: 'Override folder to upload into',
2313 typeList: [ARGUMENT_TYPE.STRING],2377 typeList: [ARGUMENT_TYPE.STRING],
2314 isRequired: false,2378 isRequired: false,
2315 acceptsMultiple: false,2379 }),
2380 SlashCommandNamedArgument.fromProps({
2381 name: 'spriteName',
2382 description: 'Override sprite name to allow multiple sprites per expressions. Has to follow the naming pattern. If unspecified, the label will be used as sprite name.',
2383 typeList: [ARGUMENT_TYPE.STRING],
2384 isRequired: false,
2316 }),2385 }),
2317 ],2386 ],
2318 helpString: '<div>Upload a sprite from a URL.</div><div>Example:</div><pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>',2387 helpString: `
2388 <div>
2389 Upload a sprite from a URL.
2390 </div>
2391 <div>
2392 <strong>Example:</strong>
2393 <ul>
2394 <li>
2395 <pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>
2396 </li>
2397 </ul>
2398 </div>
2399 `,
2319 }));2400 }));
2320})();2401})();
public/scripts/extensions/expressions/list-item.html+9 -5
@@ -1,4 +1,5 @@
1<div id="{{item}}" class="expression_list_item">1{{#each images}}
2<div class="expression_list_item interactable" data-expression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
2 <div class="expression_list_buttons">3 <div class="expression_list_buttons">
3 <div class="menu_button expression_list_upload" title="Upload image">4 <div class="menu_button expression_list_upload" title="Upload image">
4 <i class="fa-solid fa-upload"></i>5 <i class="fa-solid fa-upload"></i>
@@ -7,11 +8,14 @@
7 <i class="fa-solid fa-trash"></i>8 <i class="fa-solid fa-trash"></i>
8 </div>9 </div>
9 </div>10 </div>
10 <div class="expression_list_title {{textClass}}">11 <div class="expression_list_title">
11 <span>{{item}}</span>12 <span>{{../expression}}</span>
12 {{#if isCustom}}13 {{#if ../isCustom}}
13 <small class="expression_list_custom">(custom)</small>14 <small class="expression_list_custom">(custom)</small>
14 {{/if}}15 {{/if}}
15 </div>16 </div>
16 <img class="expression_list_image" src="{{imageSrc}}" />17 <div class="expression_list_image_container" title="{{this.title}}">
18 <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
17 </div>19 </div>
20</div>
21{{/each}}
public/scripts/extensions/expressions/settings.html+21 -9
@@ -6,17 +6,17 @@
6 </div>6 </div>
77
8 <div class="inline-drawer-content">8 <div class="inline-drawer-content">
9 <label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings.">9 <label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings." data-i18n="[title]Use the selected API from Chat Translation extension settings.">
10 <input id="expression_translate" type="checkbox">10 <input id="expression_translate" type="checkbox">
11 <span data-i18n="Translate text to English before classification">Translate text to English before classification</span>11 <span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
12 </label>12 </label>
13 <label class="checkbox_label" for="expressions_show_default">13 <label class="checkbox_label" for="expressions_allow_multiple" title="A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected." data-i18n="[title]A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.">
14 <input id="expressions_show_default" type="checkbox">14 <input id="expressions_allow_multiple" type="checkbox">
15 <span data-i18n="Show default images (emojis) if sprite missing">Show default images (emojis) if sprite missing</span>15 <span data-i18n="Allow multiple sprites per expression">Allow multiple sprites per expression</span>
16 </label>16 </label>
17 <label id="image_type_block" class="checkbox_label" for="image_type_toggle">17 <label class="checkbox_label" for="expressions_reroll_if_same" title="If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned." data-i18n="[title]If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.">
18 <input id="image_type_toggle" type="checkbox">18 <input id="expressions_reroll_if_same" type="checkbox">
19 <span data-i18n="Image Type - talkinghead (extras)">Image Type - talkinghead (extras)</span>19 <span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span>
20 </label>20 </label>
21 <div class="expression_api_block m-b-1 m-t-1">21 <div class="expression_api_block m-b-1 m-t-1">
22 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>22 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>
@@ -75,8 +75,20 @@
75 <span data-i18n="Remove all image overrides">Remove all image overrides</span>75 <span data-i18n="Remove all image overrides">Remove all image overrides</span>
76 </div>76 </div>
77 </div>77 </div>
78 <p class="hint"><b data-i18n="Hint:">Hint:</b> <i><span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>78 <p class="hint">
79 <span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt></i></p>79 <b data-i18n="Hint:">Hint:</b>
80 <i>
81 <span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
82 <span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt>
83 </i>
84 </p>
85 <p>
86 <i>
87 <span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a
88 dash.
89 Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt>
90 </i>
91 </p>
80 <h3 id="image_list_header">92 <h3 id="image_list_header">
81 <strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>93 <strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
82 </h3>94 </h3>
public/scripts/extensions/expressions/style.css+31 -2
@@ -111,6 +111,10 @@ img.expression.default {
111 justify-content: center;111 justify-content: center;
112}112}
113113
114.expression_list_image_container {
115 overflow: hidden;
116}
117
114.expression_list_title {118.expression_list_title {
115 position: absolute;119 position: absolute;
116 bottom: 0;120 bottom: 0;
@@ -126,6 +130,9 @@ img.expression.default {
126 flex-direction: column;130 flex-direction: column;
127 line-height: 1;131 line-height: 1;
128}132}
133.expression_list_custom {
134 font-size: 0.66rem;
135}
129136
130.expression_list_buttons {137.expression_list_buttons {
131 position: absolute;138 position: absolute;
@@ -162,11 +169,24 @@ img.expression.default {
162 row-gap: 1rem;169 row-gap: 1rem;
163}170}
164171
165#image_list .success {172#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
166 color: green;173 color: green;
167}174}
168175
169#image_list .failure {176#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title {
177 color: darkolivegreen;
178}
179#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before {
180 content: '➕';
181 position: absolute;
182 top: -7px;
183 left: -9px;
184 font-size: 14px;
185 color: transparent;
186 text-shadow: 0 0 0 darkolivegreen;
187}
188
189#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title {
170 color: red;190 color: red;
171}191}
172192
@@ -189,3 +209,12 @@ img.expression.default {
189 flex-direction: row;209 flex-direction: row;
190}210}
191211
212#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"],
213#expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] {
214 opacity: 0.3;
215 transition: opacity var(--animation-duration) ease;
216}
217#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover,
218#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus {
219 opacity: unset;
220}
public/scripts/extensions/expressions/templates/upload-expression.html+12 -0
@@ -0,0 +1,12 @@
1<div class="m-b-1" data-i18n="upload_expression_request">Please enter a name for the sprite (without extension).</div>
2<div class="m-b-1" data-i18n="upload_expression_naming_1">
3 Sprite names must follow the naming schema for the selected expression: {{expression}}
4</div>
5<div data-i18n="upload_expression_naming_2">
6 For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
7</div>
8<span class="m-b-1" data-i18n="Examples:">Examples:</span> <tt>{{expression}}.png</tt>, <tt>{{expression}}-1.png</tt>, <tt>{{expression}}.expressive.png</tt>
9{{#if clickedFileName}}
10<div class="m-t-1" data-i18n="upload_expression_replace">Click 'Replace' to replace the existing expression:</div>
11<tt>{{clickedFileName}}</tt>
12{{/if}}
public/scripts/extensions/token-counter/index.js+6 -20
@@ -6,6 +6,8 @@ import { getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, tokenizers
6import { resetScrollHeight, debounce } from '../../utils.js';6import { resetScrollHeight, debounce } from '../../utils.js';
7import { debounce_timeout } from '../../constants.js';7import { debounce_timeout } from '../../constants.js';
8import { POPUP_TYPE, callGenericPopup } from '../../popup.js';8import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
9import { renderExtensionTemplateAsync } from '../../extensions.js';
10import { t } from '../../i18n.js';
911
10function rgb2hex(rgb) {12function rgb2hex(rgb) {
11 rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);13 rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
@@ -22,23 +24,7 @@ $('button').click(function () {
2224
23async function doTokenCounter() {25async function doTokenCounter() {
24 const { tokenizerName, tokenizerId } = getFriendlyTokenizerName(main_api);26 const { tokenizerName, tokenizerId } = getFriendlyTokenizerName(main_api);
25 const html = `27 const html = await renderExtensionTemplateAsync('token-counter', 'window', {tokenizerName});
26 <div class="wide100p">
27 <h3>Token Counter</h3>
28 <div class="justifyLeft flex-container flexFlowColumn">
29 <h4>Type / paste in the box below to see the number of tokens in the text.</h4>
30 <p>Selected tokenizer: ${tokenizerName}</p>
31 <div>Input:</div>
32 <textarea id="token_counter_textarea" class="wide100p textarea_compact" rows="1"></textarea>
33 <div>Tokens: <span id="token_counter_result">0</span></div>
34 <hr>
35 <div>Tokenized text:</div>
36 <div id="tokenized_chunks_display" class="wide100p">—</div>
37 <hr>
38 <div>Token IDs:</div>
39 <textarea id="token_counter_ids" class="wide100p textarea_compact" readonly rows="1">—</textarea>
40 </div>
41 </div>`;
4228
43 const dialog = $(html);29 const dialog = $(html);
44 const countDebounced = debounce(async () => {30 const countDebounced = debounce(async () => {
@@ -131,9 +117,9 @@ async function doCount() {
131jQuery(() => {117jQuery(() => {
132 const buttonHtml = `118 const buttonHtml = `
133 <div id="token_counter" class="list-group-item flex-container flexGap5">119 <div id="token_counter" class="list-group-item flex-container flexGap5">
134 <div class="fa-solid fa-1 extensionsMenuExtensionButton" /></div>120 <div class="fa-solid fa-1 extensionsMenuExtensionButton" /></div>` +
135 Token Counter121 t`Token Counter` +
136 </div>`;122 '</div>';
137 $('#token_counter_wand_container').append(buttonHtml);123 $('#token_counter_wand_container').append(buttonHtml);
138 $('#token_counter').on('click', doTokenCounter);124 $('#token_counter').on('click', doTokenCounter);
139 SlashCommandParser.addCommandObject(SlashCommand.fromProps({125 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
public/scripts/extensions/token-counter/window.html+16 -0
@@ -0,0 +1,16 @@
1<div class="wide100p">
2 <h3 data-i18n="Token Counter">Token Counter</h3>
3 <div class="justifyLeft flex-container flexFlowColumn">
4 <h4 data-i18n="Type / paste in the box below to see the number of tokens in the text.">Type / paste in the box below to see the number of tokens in the text.</h4>
5 <p><span data-i18n="Selected tokenizer:">Selected tokenizer:</span> {{tokenizerName}}</p>
6 <div data-i18n="Input:">Input:</div>
7 <textarea id="token_counter_textarea" class="wide100p textarea_compact" rows="1"></textarea>
8 <div><span data-i18n="Tokens:">Tokens:</span> <span id="token_counter_result">0</span></div>
9 <hr>
10 <div data-i18n="Tokenized text:">Tokenized text:</div>
11 <div id="tokenized_chunks_display" class="wide100p">—</div>
12 <hr>
13 <div data-i18n="Token IDs:">Token IDs:</div>
14 <textarea id="token_counter_ids" class="wide100p textarea_compact" readonly rows="1">—</textarea>
15 </div>
16</div>
\ No newline at end of file16 \ No newline at end of file
public/scripts/extensions/translate/index.js+1 -1
@@ -605,7 +605,7 @@ const handleOutgoingMessage = createEventHandler(translateOutgoingMessage, () =>
605const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));605const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));
606const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);606const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);
607607
608window['translate'] = translate;608globalThis.translate = translate;
609609
610jQuery(async () => {610jQuery(async () => {
611 const html = await renderExtensionTemplateAsync('translate', 'index');611 const html = await renderExtensionTemplateAsync('translate', 'index');
public/scripts/extensions/tts/index.js+0 -26
@@ -27,14 +27,12 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
27import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';27import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
29import { GoogleTranslateTtsProvider } from './google-translate.js';29import { GoogleTranslateTtsProvider } from './google-translate.js';
30export { talkingAnimation };
3130
32const UPDATE_INTERVAL = 1000;31const UPDATE_INTERVAL = 1000;
33const wrapper = new ModuleWorkerWrapper(moduleWorker);32const wrapper = new ModuleWorkerWrapper(moduleWorker);
3433
35let voiceMapEntries = [];34let voiceMapEntries = [];
36let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}35let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
37let talkingHeadState = false;
38let lastChatId = null;36let lastChatId = null;
39let lastMessage = null;37let lastMessage = null;
40let lastMessageHash = null;38let lastMessageHash = null;
@@ -166,27 +164,6 @@ async function moduleWorker() {
166 updateUiAudioPlayState();164 updateUiAudioPlayState();
167}165}
168166
169function talkingAnimation(switchValue) {
170 if (!modules.includes('talkinghead')) {
171 console.debug('Talking Animation module not loaded');
172 return;
173 }
174
175 const apiUrl = getApiUrl();
176 const animationType = switchValue ? 'start' : 'stop';
177
178 if (switchValue !== talkingHeadState) {
179 try {
180 console.log(animationType + ' Talking Animation');
181 doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`);
182 talkingHeadState = switchValue;
183 } catch (error) {
184 // Handle the error here or simply ignore it to prevent logging
185 }
186 }
187 updateUiAudioPlayState();
188}
189
190function resetTtsPlayback() {167function resetTtsPlayback() {
191 // Stop system TTS utterance168 // Stop system TTS utterance
192 cancelTtsPlay();169 cancelTtsPlay();
@@ -378,7 +355,6 @@ function onAudioControlClicked() {
378 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful355 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
379 if (!audioElement.paused || isTtsProcessing()) {356 if (!audioElement.paused || isTtsProcessing()) {
380 resetTtsPlayback();357 resetTtsPlayback();
381 talkingAnimation(false);
382 } else {358 } else {
383 // Default play behavior if not processing or playing is to play the last message.359 // Default play behavior if not processing or playing is to play the last message.
384 processAndQueueTtsMessage(context.chat[context.chat.length - 1]);360 processAndQueueTtsMessage(context.chat[context.chat.length - 1]);
@@ -405,7 +381,6 @@ function addAudioControl() {
405function completeCurrentAudioJob() {381function completeCurrentAudioJob() {
406 audioQueueProcessorReady = true;382 audioQueueProcessorReady = true;
407 currentAudioJob = null;383 currentAudioJob = null;
408 talkingAnimation(false); //stop lip animation
409 // updateUiPlayState();384 // updateUiPlayState();
410 wrapper.update();385 wrapper.update();
411}386}
@@ -436,7 +411,6 @@ async function processAudioJobQueue() {
436 audioQueueProcessorReady = false;411 audioQueueProcessorReady = false;
437 currentAudioJob = audioJobQueue.shift();412 currentAudioJob = audioJobQueue.shift();
438 playAudioData(currentAudioJob);413 playAudioData(currentAudioJob);
439 talkingAnimation(true);
440 } catch (error) {414 } catch (error) {
441 toastr.error(error.toString());415 toastr.error(error.toString());
442 console.error(error);416 console.error(error);
public/scripts/extensions/tts/openai-compatible.js+3 -3
@@ -25,7 +25,7 @@ class OpenAICompatibleTtsProvider {
25 <label for="openai_compatible_tts_endpoint">Provider Endpoint:</label>25 <label for="openai_compatible_tts_endpoint">Provider Endpoint:</label>
26 <div class="flex-container alignItemsCenter">26 <div class="flex-container alignItemsCenter">
27 <div class="flex1">27 <div class="flex1">
28 <input id="openai_compatible_tts_endpoint" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.provider_endpoint}"/>28 <input id="openai_compatible_tts_endpoint" type="text" class="text_pole" maxlength="500" value="${this.defaultSettings.provider_endpoint}"/>
29 </div>29 </div>
30 <div id="openai_compatible_tts_key" class="menu_button menu_button_icon">30 <div id="openai_compatible_tts_key" class="menu_button menu_button_icon">
31 <i class="fa-solid fa-key"></i>31 <i class="fa-solid fa-key"></i>
@@ -33,9 +33,9 @@ class OpenAICompatibleTtsProvider {
33 </div>33 </div>
34 </div>34 </div>
35 <label for="openai_compatible_model">Model:</label>35 <label for="openai_compatible_model">Model:</label>
36 <input id="openai_compatible_model" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.model}"/>36 <input id="openai_compatible_model" type="text" class="text_pole" maxlength="500" value="${this.defaultSettings.model}"/>
37 <label for="openai_compatible_tts_voices">Available Voices (comma separated):</label>37 <label for="openai_compatible_tts_voices">Available Voices (comma separated):</label>
38 <input id="openai_compatible_tts_voices" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.available_voices.join()}"/>38 <input id="openai_compatible_tts_voices" type="text" class="text_pole" value="${this.defaultSettings.available_voices.join()}"/>
39 <label for="openai_compatible_tts_speed">Speed: <span id="openai_compatible_tts_speed_output"></span></label>39 <label for="openai_compatible_tts_speed">Speed: <span id="openai_compatible_tts_speed_output"></span></label>
40 <input type="range" id="openai_compatible_tts_speed" value="1" min="0.25" max="4" step="0.05">`;40 <input type="range" id="openai_compatible_tts_speed" value="1" min="0.25" max="4" step="0.05">`;
41 return html;41 return html;
public/scripts/extensions/tts/system.js+0 -3
@@ -1,6 +1,5 @@
1import { isMobile } from '../../RossAscends-mods.js';1import { isMobile } from '../../RossAscends-mods.js';
2import { getPreviewString } from './index.js';2import { getPreviewString } from './index.js';
3import { talkingAnimation } from './index.js';
4import { saveTtsProviderSettings } from './index.js';3import { saveTtsProviderSettings } from './index.js';
5export { SystemTtsProvider };4export { SystemTtsProvider };
65
@@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) {
70 //placing the speak invocation inside a callback fixes ordering and onend issues.69 //placing the speak invocation inside a callback fixes ordering and onend issues.
71 setTimeout(function () {70 setTimeout(function () {
72 speechSynthesis.speak(newUtt);71 speechSynthesis.speak(newUtt);
73 talkingAnimation(true);
74 }, 0);72 }, 0);
75};73};
7674
@@ -240,7 +238,6 @@ class SystemTtsProvider {
240 //some code to execute when done238 //some code to execute when done
241 resolve(silence);239 resolve(silence);
242 console.log('System TTS done');240 console.log('System TTS done');
243 talkingAnimation(false);
244 });241 });
245 });242 });
246 }243 }
public/scripts/extensions/vectors/index.js+58 -64
@@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) {
561 */561 */
562async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {562async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {
563 try {563 try {
564 if (settings.translate_files && typeof window['translate'] === 'function') {564 if (settings.translate_files && typeof globalThis.translate === 'function') {
565 console.log(`Vectors: Translating file ${fileName} to English...`);565 console.log(`Vectors: Translating file ${fileName} to English...`);
566 const translatedText = await window['translate'](fileText, 'en');566 const translatedText = await globalThis.translate(fileText, 'en');
567 fileText = translatedText;567 fileText = translatedText;
568 }568 }
569569
@@ -746,74 +746,65 @@ async function getQueryText(chat, initiator) {
746}746}
747747
748/**748/**
749 * Gets the saved hashes for a collection749 * Gets common body parameters for vector requests.
750* @param {string} collectionId750 * @returns {object}
751* @returns {Promise<number[]>} Saved hashes
752 */751 */
753async function getSavedHashes(collectionId) {752function getVectorsRequestBody() {
754 const response = await fetch('/api/vector/list', {753 const body = {};
755 method: 'POST',
756 headers: getVectorHeaders(),
757 body: JSON.stringify({
758 collectionId: collectionId,
759 source: settings.source,
760 }),
761 });
762
763 if (!response.ok) {
764 throw new Error(`Failed to get saved hashes for collection ${collectionId}`);
765 }
766
767 const hashes = await response.json();
768 return hashes;
769}
770
771function getVectorHeaders() {
772 const headers = getRequestHeaders();
773 switch (settings.source) {754 switch (settings.source) {
774 case 'extras':755 case 'extras':
775 Object.assign(headers, {756 body.extrasUrl = extension_settings.apiUrl;
776 'X-Extras-Url': extension_settings.apiUrl,757 body.extrasKey = extension_settings.apiKey;
777 'X-Extras-Key': extension_settings.apiKey,
778 });
779 break;758 break;
780 case 'togetherai':759 case 'togetherai':
781 Object.assign(headers, {760 body.model = extension_settings.vectors.togetherai_model;
782 'X-Togetherai-Model': extension_settings.vectors.togetherai_model,
783 });
784 break;761 break;
785 case 'openai':762 case 'openai':
786 Object.assign(headers, {763 body.model = extension_settings.vectors.openai_model;
787 'X-OpenAI-Model': extension_settings.vectors.openai_model,
788 });
789 break;764 break;
790 case 'cohere':765 case 'cohere':
791 Object.assign(headers, {766 body.model = extension_settings.vectors.cohere_model;
792 'X-Cohere-Model': extension_settings.vectors.cohere_model,
793 });
794 break;767 break;
795 case 'ollama':768 case 'ollama':
796 Object.assign(headers, {769 body.model = extension_settings.vectors.ollama_model;
797 'X-Ollama-Model': extension_settings.vectors.ollama_model,770 body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.OLLAMA];
798 'X-Ollama-URL': textgenerationwebui_settings.server_urls[textgen_types.OLLAMA],771 body.keep = !!extension_settings.vectors.ollama_keep;
799 'X-Ollama-Keep': !!extension_settings.vectors.ollama_keep,
800 });
801 break;772 break;
802 case 'llamacpp':773 case 'llamacpp':
803 Object.assign(headers, {774 body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP];
804 'X-LlamaCpp-URL': textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP],
805 });
806 break;775 break;
807 case 'vllm':776 case 'vllm':
808 Object.assign(headers, {777 body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.VLLM];
809 'X-Vllm-URL': textgenerationwebui_settings.server_urls[textgen_types.VLLM],778 body.model = extension_settings.vectors.vllm_model;
810 'X-Vllm-Model': extension_settings.vectors.vllm_model,
811 });
812 break;779 break;
813 default:780 default:
814 break;781 break;
815 }782 }
816 return headers;783 return body;
784}
785
786/**
787 * Gets the saved hashes for a collection
788* @param {string} collectionId
789* @returns {Promise<number[]>} Saved hashes
790*/
791async function getSavedHashes(collectionId) {
792 const response = await fetch('/api/vector/list', {
793 method: 'POST',
794 headers: getRequestHeaders(),
795 body: JSON.stringify({
796 ...getVectorsRequestBody(),
797 collectionId: collectionId,
798 source: settings.source,
799 }),
800 });
801
802 if (!response.ok) {
803 throw new Error(`Failed to get saved hashes for collection ${collectionId}`);
804 }
805
806 const hashes = await response.json();
807 return hashes;
817}808}
818809
819/**810/**
@@ -825,12 +816,11 @@ function getVectorHeaders() {
825async function insertVectorItems(collectionId, items) {816async function insertVectorItems(collectionId, items) {
826 throwIfSourceInvalid();817 throwIfSourceInvalid();
827818
828 const headers = getVectorHeaders();
829
830 const response = await fetch('/api/vector/insert', {819 const response = await fetch('/api/vector/insert', {
831 method: 'POST',820 method: 'POST',
832 headers: headers,821 headers: getRequestHeaders(),
833 body: JSON.stringify({822 body: JSON.stringify({
823 ...getVectorsRequestBody(),
834 collectionId: collectionId,824 collectionId: collectionId,
835 items: items,825 items: items,
836 source: settings.source,826 source: settings.source,
@@ -879,8 +869,9 @@ function throwIfSourceInvalid() {
879async function deleteVectorItems(collectionId, hashes) {869async function deleteVectorItems(collectionId, hashes) {
880 const response = await fetch('/api/vector/delete', {870 const response = await fetch('/api/vector/delete', {
881 method: 'POST',871 method: 'POST',
882 headers: getVectorHeaders(),872 headers: getRequestHeaders(),
883 body: JSON.stringify({873 body: JSON.stringify({
874 ...getVectorsRequestBody(),
884 collectionId: collectionId,875 collectionId: collectionId,
885 hashes: hashes,876 hashes: hashes,
886 source: settings.source,877 source: settings.source,
@@ -899,12 +890,11 @@ async function deleteVectorItems(collectionId, hashes) {
899 * @returns {Promise<{ hashes: number[], metadata: object[]}>} - Hashes of the results890 * @returns {Promise<{ hashes: number[], metadata: object[]}>} - Hashes of the results
900 */891 */
901async function queryCollection(collectionId, searchText, topK) {892async function queryCollection(collectionId, searchText, topK) {
902 const headers = getVectorHeaders();
903
904 const response = await fetch('/api/vector/query', {893 const response = await fetch('/api/vector/query', {
905 method: 'POST',894 method: 'POST',
906 headers: headers,895 headers: getRequestHeaders(),
907 body: JSON.stringify({896 body: JSON.stringify({
897 ...getVectorsRequestBody(),
908 collectionId: collectionId,898 collectionId: collectionId,
909 searchText: searchText,899 searchText: searchText,
910 topK: topK,900 topK: topK,
@@ -929,12 +919,11 @@ async function queryCollection(collectionId, searchText, topK) {
929 * @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - Results mapped to collection IDs919 * @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - Results mapped to collection IDs
930 */920 */
931async function queryMultipleCollections(collectionIds, searchText, topK, threshold) {921async function queryMultipleCollections(collectionIds, searchText, topK, threshold) {
932 const headers = getVectorHeaders();
933
934 const response = await fetch('/api/vector/query-multi', {922 const response = await fetch('/api/vector/query-multi', {
935 method: 'POST',923 method: 'POST',
936 headers: headers,924 headers: getRequestHeaders(),
937 body: JSON.stringify({925 body: JSON.stringify({
926 ...getVectorsRequestBody(),
938 collectionIds: collectionIds,927 collectionIds: collectionIds,
939 searchText: searchText,928 searchText: searchText,
940 topK: topK,929 topK: topK,
@@ -965,8 +954,9 @@ async function purgeFileVectorIndex(fileUrl) {
965954
966 const response = await fetch('/api/vector/purge', {955 const response = await fetch('/api/vector/purge', {
967 method: 'POST',956 method: 'POST',
968 headers: getVectorHeaders(),957 headers: getRequestHeaders(),
969 body: JSON.stringify({958 body: JSON.stringify({
959 ...getVectorsRequestBody(),
970 collectionId: collectionId,960 collectionId: collectionId,
971 }),961 }),
972 });962 });
@@ -994,8 +984,9 @@ async function purgeVectorIndex(collectionId) {
994984
995 const response = await fetch('/api/vector/purge', {985 const response = await fetch('/api/vector/purge', {
996 method: 'POST',986 method: 'POST',
997 headers: getVectorHeaders(),987 headers: getRequestHeaders(),
998 body: JSON.stringify({988 body: JSON.stringify({
989 ...getVectorsRequestBody(),
999 collectionId: collectionId,990 collectionId: collectionId,
1000 }),991 }),
1001 });992 });
@@ -1019,7 +1010,10 @@ async function purgeAllVectorIndexes() {
1019 try {1010 try {
1020 const response = await fetch('/api/vector/purge-all', {1011 const response = await fetch('/api/vector/purge-all', {
1021 method: 'POST',1012 method: 'POST',
1022 headers: getVectorHeaders(),1013 headers: getRequestHeaders(),
1014 body: JSON.stringify({
1015 ...getVectorsRequestBody(),
1016 }),
1023 });1017 });
10241018
1025 if (!response.ok) {1019 if (!response.ok) {
public/scripts/group-chats.js+5 -2
@@ -1664,12 +1664,12 @@ function updateFavButtonState(state) {
1664export async function openGroupById(groupId) {1664export async function openGroupById(groupId) {
1665 if (isChatSaving) {1665 if (isChatSaving) {
1666 toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`);1666 toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`);
1667 return;1667 return false;
1668 }1668 }
16691669
1670 if (!groups.find(x => x.id === groupId)) {1670 if (!groups.find(x => x.id === groupId)) {
1671 console.log('Group not found', groupId);1671 console.log('Group not found', groupId);
1672 return;1672 return false;
1673 }1673 }
16741674
1675 if (!is_send_press && !is_group_generating) {1675 if (!is_send_press && !is_group_generating) {
@@ -1686,8 +1686,11 @@ export async function openGroupById(groupId) {
1686 updateChatMetadata({}, true);1686 updateChatMetadata({}, true);
1687 chat.length = 0;1687 chat.length = 0;
1688 await getGroupChat(groupId);1688 await getGroupChat(groupId);
1689 return true;
1689 }1690 }
1690 }1691 }
1692
1693 return false;
1691}1694}
16921695
1693function openCharacterDefinition(characterSelect) {1696function openCharacterDefinition(characterSelect) {
public/scripts/openai.js+45 -28
@@ -2167,6 +2167,14 @@ function getStreamingReply(data, state) {
2167 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');2167 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
2168 }2168 }
2169 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';2169 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
2170 } else if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) {
2171 if (oai_settings.show_thoughts) {
2172 state.reasoning +=
2173 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
2174 data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ??
2175 '';
2176 }
2177 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
2170 } else {2178 } else {
2171 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';2179 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
2172 }2180 }
@@ -4107,6 +4115,40 @@ function getMaxContextWindowAI(value) {
4107 }4115 }
4108}4116}
41094117
4118/**
4119 * Get the maximum context size for the Groq model
4120 * @param {string} model Model identifier
4121 * @param {boolean} isUnlocked Whether context limits are unlocked
4122 * @returns {number} Maximum context size in tokens
4123 */
4124function getGroqMaxContext(model, isUnlocked) {
4125 if (isUnlocked) {
4126 return unlocked_max;
4127 }
4128
4129 const contextMap = {
4130 'gemma2-9b-it': max_8k,
4131 'llama-3.3-70b-versatile': max_128k,
4132 'llama-3.1-8b-instant': max_128k,
4133 'llama3-70b-8192': max_8k,
4134 'llama3-8b-8192': max_8k,
4135 'llama-guard-3-8b': max_8k,
4136 'mixtral-8x7b-32768': max_32k,
4137 'deepseek-r1-distill-llama-70b': max_128k,
4138 'llama-3.3-70b-specdec': max_8k,
4139 'llama-3.2-1b-preview': max_128k,
4140 'llama-3.2-3b-preview': max_128k,
4141 'llama-3.2-11b-vision-preview': max_128k,
4142 'llama-3.2-90b-vision-preview': max_128k,
4143 'qwen-2.5-32b': max_128k,
4144 'deepseek-r1-distill-qwen-32b': max_128k,
4145 'deepseek-r1-distill-llama-70b-specdec': max_128k,
4146 };
4147
4148 // Return context size if model found, otherwise default to 128k
4149 return Object.entries(contextMap).find(([key]) => model.includes(key))?.[1] || max_128k;
4150}
4151
4110async function onModelChange() {4152async function onModelChange() {
4111 biasCache = undefined;4153 biasCache = undefined;
4112 let value = String($(this).val() || '');4154 let value = String($(this).val() || '');
@@ -4387,7 +4429,7 @@ async function onModelChange() {
4387 if (oai_settings.max_context_unlocked) {4429 if (oai_settings.max_context_unlocked) {
4388 $('#openai_max_context').attr('max', unlocked_max);4430 $('#openai_max_context').attr('max', unlocked_max);
4389 }4431 }
4390 else if (['sonar', 'sonar-reasoning'].includes(oai_settings.perplexity_model)) {4432 else if (['sonar', 'sonar-reasoning', 'sonar-reasoning-pro', 'r1-1776'].includes(oai_settings.perplexity_model)) {
4391 $('#openai_max_context').attr('max', 127000);4433 $('#openai_max_context').attr('max', 127000);
4392 }4434 }
4393 else if (['sonar-pro'].includes(oai_settings.perplexity_model)) {4435 else if (['sonar-pro'].includes(oai_settings.perplexity_model)) {
@@ -4408,33 +4450,8 @@ async function onModelChange() {
4408 }4450 }
44094451
4410 if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) {4452 if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) {
4411 if (oai_settings.max_context_unlocked) {4453 const maxContext = getGroqMaxContext(oai_settings.groq_model, oai_settings.max_context_unlocked);
4412 $('#openai_max_context').attr('max', unlocked_max);4454 $('#openai_max_context').attr('max', maxContext);
4413 } else if (oai_settings.groq_model.includes('gemma2-9b-it')) {
4414 $('#openai_max_context').attr('max', max_8k);
4415 } else if (oai_settings.groq_model.includes('llama-3.3-70b-versatile')) {
4416 $('#openai_max_context').attr('max', max_128k);
4417 } else if (oai_settings.groq_model.includes('llama-3.1-8b-instant')) {
4418 $('#openai_max_context').attr('max', max_128k);
4419 } else if (oai_settings.groq_model.includes('llama3-70b-8192')) {
4420 $('#openai_max_context').attr('max', max_8k);
4421 } else if (oai_settings.groq_model.includes('llama3-8b-8192')) {
4422 $('#openai_max_context').attr('max', max_8k);
4423 } else if (oai_settings.groq_model.includes('mixtral-8x7b-32768')) {
4424 $('#openai_max_context').attr('max', max_32k);
4425 } else if (oai_settings.groq_model.includes('deepseek-r1-distill-llama-70b')) {
4426 $('#openai_max_context').attr('max', max_128k);
4427 } else if (oai_settings.groq_model.includes('llama-3.3-70b-specdec')) {
4428 $('#openai_max_context').attr('max', max_8k);
4429 } else if (oai_settings.groq_model.includes('llama-3.2-1b-preview')) {
4430 $('#openai_max_context').attr('max', max_128k);
4431 } else if (oai_settings.groq_model.includes('llama-3.2-3b-preview')) {
4432 $('#openai_max_context').attr('max', max_128k);
4433 } else if (oai_settings.groq_model.includes('llama-3.2-11b-vision-preview')) {
4434 $('#openai_max_context').attr('max', max_128k);
4435 } else if (oai_settings.groq_model.includes('llama-3.2-90b-vision-preview')) {
4436 $('#openai_max_context').attr('max', max_128k);
4437 }
4438 oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);4455 oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
4439 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');4456 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
4440 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);4457 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
public/scripts/power-user.js+5 -4
@@ -1845,14 +1845,15 @@ async function loadContextSettings() {
18451845
1846/**1846/**
1847 * Common function to perform fuzzy search with optional caching1847 * Common function to perform fuzzy search with optional caching
1848 * @template T
1848 * @param {string} type - Type of search from fuzzySearchCategories1849 * @param {string} type - Type of search from fuzzySearchCategories
1849 * @param {any[]} data - Data array to search in1850 * @param {T[]} data - Data array to search in
1850 * @param {Array<{name: string, weight: number, getFn?: (obj: any) => string}>} keys - Fuse.js keys configuration1851 * @param {Array<{name: string, weight: number, getFn?: (obj: T) => string}>} keys - Fuse.js keys configuration
1851 * @param {string} searchValue - The search term1852 * @param {string} searchValue - The search term
1852 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches1853 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1853 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score1854 * @returns {import('fuse.js').FuseResult<T>[]} Results as items with their score
1854 */1855 */
1855function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {1856export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
1856 // Check cache if provided1857 // Check cache if provided
1857 if (fuzzySearchCaches) {1858 if (fuzzySearchCaches) {
1858 const cache = fuzzySearchCaches[type];1859 const cache = fuzzySearchCaches[type];
public/scripts/reasoning.js+192 -37
@@ -1,19 +1,32 @@
1import {1import {
2 moment,2 moment,
3} from '../lib.js';3} from '../lib.js';
4import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js';4import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveChatDebounced, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js';
5import { getRegexedString, regex_placement } from './extensions/regex/engine.js';5import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
6import { getCurrentLocale, t } from './i18n.js';6import { getCurrentLocale, t, translate } from './i18n.js';
7import { MacrosParser } from './macros.js';7import { MacrosParser } from './macros.js';
8import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';8import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';
9import { Popup } from './popup.js';9import { Popup } from './popup.js';
10import { power_user } from './power-user.js';10import { power_user } from './power-user.js';
11import { SlashCommand } from './slash-commands/SlashCommand.js';11import { SlashCommand } from './slash-commands/SlashCommand.js';
12import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';12import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
13import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';13import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
14import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
14import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';15import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
15import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';16import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
16import { copyText, escapeRegex, isFalseBoolean, setDatasetProperty } from './utils.js';17import { copyText, escapeRegex, isFalseBoolean, setDatasetProperty, trimSpaces } from './utils.js';
18
19/**
20 * Enum representing the type of the reasoning for a message (where it came from)
21 * @enum {string}
22 * @readonly
23 */
24export const ReasoningType = {
25 Model: 'model',
26 Parsed: 'parsed',
27 Manual: 'manual',
28 Edited: 'edited',
29};
1730
18/**31/**
19 * Gets a message from a jQuery element.32 * Gets a message from a jQuery element.
@@ -63,6 +76,11 @@ export function extractReasoningFromData(data) {
63 return data?.choices?.[0]?.message?.reasoning ?? '';76 return data?.choices?.[0]?.message?.reasoning ?? '';
64 case chat_completion_sources.MAKERSUITE:77 case chat_completion_sources.MAKERSUITE:
65 return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? '';78 return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? '';
79 case chat_completion_sources.CUSTOM: {
80 return data?.choices?.[0]?.message?.reasoning_content
81 ?? data?.choices?.[0]?.message?.reasoning
82 ?? '';
83 }
66 }84 }
67 break;85 break;
68 }86 }
@@ -94,7 +112,7 @@ export function isHiddenReasoningModel() {
94 { name: 'gemini-2.0-pro-exp', func: FUNCS.startsWith },112 { name: 'gemini-2.0-pro-exp', func: FUNCS.startsWith },
95 ];113 ];
96114
97 const model = getChatCompletionModel();115 const model = getChatCompletionModel() || '';
98116
99 const isHidden = hiddenReasoningModels.some(({ name, func }) => func(model, name));117 const isHidden = hiddenReasoningModels.some(({ name, func }) => func(model, name));
100 return isHidden;118 return isHidden;
@@ -129,7 +147,12 @@ export const ReasoningState = {
129 * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates.147 * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates.
130 */148 */
131export class ReasoningHandler {149export class ReasoningHandler {
150 /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
132 #isHiddenReasoningModel;151 #isHiddenReasoningModel;
152 /** @type {boolean} True if the handler is currently handling a manual parse of reasoning blocks */
153 #isParsingReasoning = false;
154 /** @type {number?} When reasoning is being parsed manually, and the reasoning has ended, this will be the index at which the actual messages starts */
155 #parsingReasoningMesStartIndex = null;
133156
134 /**157 /**
135 * @param {Date?} [timeStarted=null] - When the generation started158 * @param {Date?} [timeStarted=null] - When the generation started
@@ -137,6 +160,8 @@ export class ReasoningHandler {
137 constructor(timeStarted = null) {160 constructor(timeStarted = null) {
138 /** @type {ReasoningState} The current state of the reasoning process */161 /** @type {ReasoningState} The current state of the reasoning process */
139 this.state = ReasoningState.None;162 this.state = ReasoningState.None;
163 /** @type {ReasoningType?} The type of the reasoning (where it came from) */
164 this.type = null;
140 /** @type {string} The reasoning output */165 /** @type {string} The reasoning output */
141 this.reasoning = '';166 this.reasoning = '';
142 /** @type {Date} When the reasoning started */167 /** @type {Date} When the reasoning started */
@@ -147,7 +172,6 @@ export class ReasoningHandler {
147 /** @type {Date} Initial starting time of the generation */172 /** @type {Date} Initial starting time of the generation */
148 this.initialTime = timeStarted ?? new Date();173 this.initialTime = timeStarted ?? new Date();
149174
150 /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
151 this.#isHiddenReasoningModel = isHiddenReasoningModel();175 this.#isHiddenReasoningModel = isHiddenReasoningModel();
152176
153 // Cached DOM elements for reasoning177 // Cached DOM elements for reasoning
@@ -194,6 +218,7 @@ export class ReasoningHandler {
194 this.state = ReasoningState.Hidden;218 this.state = ReasoningState.Hidden;
195 }219 }
196220
221 this.type = extra?.reasoning_type;
197 this.reasoning = extra?.reasoning ?? '';222 this.reasoning = extra?.reasoning ?? '';
198223
199 if (this.state !== ReasoningState.None) {224 if (this.state !== ReasoningState.None) {
@@ -208,6 +233,7 @@ export class ReasoningHandler {
208 // Make sure reset correctly clears all relevant states233 // Make sure reset correctly clears all relevant states
209 if (reset) {234 if (reset) {
210 this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None;235 this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None;
236 this.type = null;
211 this.reasoning = '';237 this.reasoning = '';
212 this.initialTime = new Date();238 this.initialTime = new Date();
213 this.startTime = null;239 this.startTime = null;
@@ -237,18 +263,19 @@ export class ReasoningHandler {
237 * Updates the reasoning text/string for a message.263 * Updates the reasoning text/string for a message.
238 *264 *
239 * @param {number} messageId - The ID of the message to update265 * @param {number} messageId - The ID of the message to update
240 * @param {string?} [reasoning=null] - The reasoning text to update - If null, uses the current reasoning266 * @param {string?} [reasoning=null] - The reasoning text to update - If null or empty, uses the current reasoning
241 * @param {Object} [options={}] - Optional arguments267 * @param {Object} [options={}] - Optional arguments
242 * @param {boolean} [options.persist=false] - Whether to persist the reasoning to the message object268 * @param {boolean} [options.persist=false] - Whether to persist the reasoning to the message object
269 * @param {boolean} [options.allowReset=false] - Whether to allow empty reasoning provided to reset the reasoning, instead of just taking the existing one
243 * @returns {boolean} - Returns true if the reasoning was changed, otherwise false270 * @returns {boolean} - Returns true if the reasoning was changed, otherwise false
244 */271 */
245 updateReasoning(messageId, reasoning = null, { persist = false } = {}) {272 updateReasoning(messageId, reasoning = null, { persist = false, allowReset = false } = {}) {
246 if (messageId == -1 || !chat[messageId]) {273 if (messageId == -1 || !chat[messageId]) {
247 return false;274 return false;
248 }275 }
249276
250 reasoning = reasoning ?? this.reasoning;277 reasoning = allowReset ? reasoning ?? this.reasoning : reasoning || this.reasoning;
251 reasoning = power_user.trim_spaces ? reasoning.trim() : reasoning;278 reasoning = trimSpaces(reasoning);
252279
253 // Ensure the chat extra exists280 // Ensure the chat extra exists
254 if (!chat[messageId].extra) {281 if (!chat[messageId].extra) {
@@ -259,10 +286,13 @@ export class ReasoningHandler {
259 const reasoningChanged = extra.reasoning !== reasoning;286 const reasoningChanged = extra.reasoning !== reasoning;
260 this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING);287 this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING);
261288
289 this.type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
290
262 if (persist) {291 if (persist) {
263 // Build and save the reasoning data to message extras292 // Build and save the reasoning data to message extras
264 extra.reasoning = this.reasoning;293 extra.reasoning = this.reasoning;
265 extra.reasoning_duration = this.getDuration();294 extra.reasoning_duration = this.getDuration();
295 extra.reasoning_type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
266 }296 }
267297
268 return reasoningChanged;298 return reasoningChanged;
@@ -279,7 +309,10 @@ export class ReasoningHandler {
279 * @returns {Promise<void>}309 * @returns {Promise<void>}
280 */310 */
281 async process(messageId, mesChanged) {311 async process(messageId, mesChanged) {
282 if (!this.reasoning && !this.#isHiddenReasoningModel) return;312 mesChanged = this.#autoParseReasoningFromMessage(messageId, mesChanged);
313
314 if (!this.reasoning && !this.#isHiddenReasoningModel)
315 return;
283316
284 // Ensure reasoning string is updated and regexes are applied correctly317 // Ensure reasoning string is updated and regexes are applied correctly
285 const reasoningChanged = this.updateReasoning(messageId, null, { persist: true });318 const reasoningChanged = this.updateReasoning(messageId, null, { persist: true });
@@ -294,6 +327,54 @@ export class ReasoningHandler {
294 }327 }
295 }328 }
296329
330 #autoParseReasoningFromMessage(messageId, mesChanged) {
331 if (!power_user.reasoning.auto_parse)
332 return;
333 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix)
334 return mesChanged;
335
336 /** @type {{ mes: string, [key: string]: any}} */
337 const message = chat[messageId];
338 if (!message) return mesChanged;
339
340 // If we are done with reasoning parse, we just split the message correctly so the reasoning doesn't show up inside of it.
341 if (this.#parsingReasoningMesStartIndex) {
342 message.mes = trimSpaces(message.mes.slice(this.#parsingReasoningMesStartIndex));
343 return mesChanged;
344 }
345
346 if (this.state === ReasoningState.None || this.#isHiddenReasoningModel) {
347 // If streamed message starts with the opening, cut it out and put all inside reasoning
348 if (message.mes.startsWith(power_user.reasoning.prefix) && message.mes.length > power_user.reasoning.prefix.length) {
349 this.#isParsingReasoning = true;
350
351 // Manually set starting state here, as we might already have received the ending suffix
352 this.state = ReasoningState.Thinking;
353 this.startTime = this.startTime ?? this.initialTime;
354 this.endTime = null;
355 }
356 }
357
358 if (!this.#isParsingReasoning)
359 return mesChanged;
360
361 // If we are in manual parsing mode, all currently streaming mes tokens will go the the reasoning block
362 const originalMes = message.mes;
363 this.reasoning = originalMes.slice(power_user.reasoning.prefix.length);
364 message.mes = '';
365
366 // If the reasoning contains the ending suffix, we cut that off and continue as message streaming
367 if (this.reasoning.includes(power_user.reasoning.suffix)) {
368 this.reasoning = this.reasoning.slice(0, this.reasoning.indexOf(power_user.reasoning.suffix));
369 this.#parsingReasoningMesStartIndex = originalMes.indexOf(power_user.reasoning.suffix) + power_user.reasoning.suffix.length;
370 message.mes = trimSpaces(originalMes.slice(this.#parsingReasoningMesStartIndex));
371 this.#isParsingReasoning = false;
372 }
373
374 // Only return the original mesChanged value if we haven't cut off the complete message
375 return message.mes.length ? mesChanged : false;
376 }
377
297 /**378 /**
298 * Completes the reasoning process for a message.379 * Completes the reasoning process for a message.
299 *380 *
@@ -336,9 +417,10 @@ export class ReasoningHandler {
336 // Update states to the relevant DOM elements417 // Update states to the relevant DOM elements
337 setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null);418 setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null);
338 setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state);419 setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state);
420 setDatasetProperty(this.messageReasoningDetailsDom, 'type', this.type);
339421
340 // Update the reasoning message422 // Update the reasoning message
341 const reasoning = power_user.trim_spaces ? this.reasoning.trim() : this.reasoning;423 const reasoning = trimSpaces(this.reasoning);
342 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);424 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
343 this.messageReasoningContentDom.innerHTML = displayReasoning;425 this.messageReasoningContentDom.innerHTML = displayReasoning;
344426
@@ -393,17 +475,14 @@ export class ReasoningHandler {
393 const element = this.messageReasoningHeaderDom;475 const element = this.messageReasoningHeaderDom;
394 const duration = this.getDuration();476 const duration = this.getDuration();
395 let data = null;477 let data = null;
478 let title = '';
396 if (duration) {479 if (duration) {
397 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });480 const seconds = moment.duration(duration).asSeconds();
398 const secondsStr = moment.duration(duration).asSeconds();
399
400 const span = document.createElement('span');
401 span.title = t`${secondsStr} seconds`;
402 span.textContent = durationStr;
403481
404 element.textContent = t`Thought for `;482 const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });
405 element.appendChild(span);483 element.textContent = t`Thought for ${durationStr}`;
406 data = String(secondsStr);484 data = String(seconds);
485 title = `${seconds} seconds`;
407 } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) {486 } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) {
408 element.textContent = t`Thought for some time`;487 element.textContent = t`Thought for some time`;
409 data = 'unknown';488 data = 'unknown';
@@ -412,6 +491,12 @@ export class ReasoningHandler {
412 data = null;491 data = null;
413 }492 }
414493
494 if (this.type !== ReasoningType.Model) {
495 title += ` [${translate(this.type)}]`;
496 title = title.trim();
497 }
498 element.title = title;
499
415 setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data);500 setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data);
416 setDatasetProperty(element, 'duration', data);501 setDatasetProperty(element, 'duration', data);
417 }502 }
@@ -573,11 +658,16 @@ function registerReasoningSlashCommands() {
573 callback: async (args, value) => {658 callback: async (args, value) => {
574 const messageId = !isNaN(Number(args.at)) ? Number(args.at) : chat.length - 1;659 const messageId = !isNaN(Number(args.at)) ? Number(args.at) : chat.length - 1;
575 const message = chat[messageId];660 const message = chat[messageId];
576 if (!message?.extra) {661 if (!message) {
577 return '';662 return '';
578 }663 }
664 // Make sure the message has an extra object
665 if (!message.extra || typeof message.extra !== 'object') {
666 message.extra = {};
667 }
579668
580 message.extra.reasoning = String(value ?? '');669 message.extra.reasoning = String(value ?? '');
670 message.extra.reasoning_type = ReasoningType.Manual;
581 await saveChatConditional();671 await saveChatConditional();
582672
583 closeMessageEditor('reasoning');673 closeMessageEditor('reasoning');
@@ -598,7 +688,26 @@ function registerReasoningSlashCommands() {
598 typeList: [ARGUMENT_TYPE.BOOLEAN],688 typeList: [ARGUMENT_TYPE.BOOLEAN],
599 defaultValue: 'true',689 defaultValue: 'true',
600 isRequired: false,690 isRequired: false,
601 enumProvider: commonEnumProviders.boolean('trueFalse'),691 enumList: commonEnumProviders.boolean('trueFalse')(),
692 }),
693 SlashCommandNamedArgument.fromProps({
694 name: 'return',
695 description: 'Whether to return the parsed reasoning or the content without reasoning',
696 typeList: [ARGUMENT_TYPE.STRING],
697 defaultValue: 'reasoning',
698 isRequired: false,
699 enumList: [
700 new SlashCommandEnumValue('reasoning', null, enumTypes.enum, enumIcons.reasoning),
701 new SlashCommandEnumValue('content', null, enumTypes.enum, enumIcons.message),
702 ],
703 }),
704 SlashCommandNamedArgument.fromProps({
705 name: 'strict',
706 description: 'Whether to require the reasoning block to be at the beginning of the string (excluding whitespaces).',
707 typeList: [ARGUMENT_TYPE.BOOLEAN],
708 defaultValue: 'true',
709 isRequired: false,
710 enumList: commonEnumProviders.boolean('trueFalse')(),
602 }),711 }),
603 ],712 ],
604 unnamedArgumentList: [713 unnamedArgumentList: [
@@ -608,19 +717,27 @@ function registerReasoningSlashCommands() {
608 }),717 }),
609 ],718 ],
610 callback: (args, value) => {719 callback: (args, value) => {
611 if (!value) {720 if (!value || typeof value !== 'string') {
612 return '';721 return '';
613 }722 }
614723
615 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {724 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
616 toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`);725 toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Parse`);
617 return String(value);726 return value;
727 }
728 if (typeof args.return !== 'string' || !['reasoning', 'content'].includes(args.return)) {
729 toastr.warning(t`Invalid return type '${args.return}', defaulting to 'reasoning'.`, t`Reasoning Parse`);
618 }730 }
619731
620 const parsedReasoning = parseReasoningFromString(String(value));732 const returnMessage = args.return === 'content';
621733
734 const parsedReasoning = parseReasoningFromString(value, { strict: !isFalseBoolean(String(args.strict ?? '')) });
622 if (!parsedReasoning) {735 if (!parsedReasoning) {
623 return '';736 return returnMessage ? value : '';
737 }
738
739 if (returnMessage) {
740 return parsedReasoning.content;
624 }741 }
625742
626 const applyRegex = !isFalseBoolean(String(args.regex ?? ''));743 const applyRegex = !isFalseBoolean(String(args.regex ?? ''));
@@ -638,6 +755,17 @@ function registerReasoningMacros() {
638}755}
639756
640function setReasoningEventHandlers() {757function setReasoningEventHandlers() {
758 /**
759 * Updates the reasoning block of a message from a value.
760 * @param {object} message Message object
761 * @param {string} value Reasoning value
762 */
763 function updateReasoningFromValue(message, value) {
764 const reasoning = getRegexedString(value, regex_placement.REASONING, { isEdit: true });
765 message.extra.reasoning = reasoning;
766 message.extra.reasoning_type = message.extra.reasoning_type ? ReasoningType.Edited : ReasoningType.Manual;
767 }
768
641 $(document).on('click', '.mes_reasoning_details', function (e) {769 $(document).on('click', '.mes_reasoning_details', function (e) {
642 if (!e.target.closest('.mes_reasoning_actions') && !e.target.closest('.mes_reasoning_header')) {770 if (!e.target.closest('.mes_reasoning_actions') && !e.target.closest('.mes_reasoning_header')) {
643 e.preventDefault();771 e.preventDefault();
@@ -718,8 +846,7 @@ function setReasoningEventHandlers() {
718 }846 }
719847
720 const textarea = messageBlock.find('.reasoning_edit_textarea');848 const textarea = messageBlock.find('.reasoning_edit_textarea');
721 const reasoning = getRegexedString(String(textarea.val()), regex_placement.REASONING, { isEdit: true });849 updateReasoningFromValue(message, String(textarea.val()));
722 message.extra.reasoning = reasoning;
723 await saveChatConditional();850 await saveChatConditional();
724 updateMessageBlock(messageId, message);851 updateMessageBlock(messageId, message);
725 textarea.remove();852 textarea.remove();
@@ -780,6 +907,8 @@ function setReasoningEventHandlers() {
780 return;907 return;
781 }908 }
782 message.extra.reasoning = '';909 message.extra.reasoning = '';
910 delete message.extra.reasoning_type;
911 delete message.extra.reasoning_duration;
783 await saveChatConditional();912 await saveChatConditional();
784 updateMessageBlock(messageId, message);913 updateMessageBlock(messageId, message);
785 const textarea = messageBlock.find('.reasoning_edit_textarea');914 const textarea = messageBlock.find('.reasoning_edit_textarea');
@@ -797,6 +926,20 @@ function setReasoningEventHandlers() {
797 await copyText(reasoning);926 await copyText(reasoning);
798 toastr.info(t`Copied!`, '', { timeOut: 2000 });927 toastr.info(t`Copied!`, '', { timeOut: 2000 });
799 });928 });
929
930 $(document).on('input', '.reasoning_edit_textarea', function () {
931 if (!power_user.auto_save_msg_edits) {
932 return;
933 }
934
935 const { message } = getMessageFromJquery(this);
936 if (!message?.extra) {
937 return;
938 }
939
940 updateReasoningFromValue(message, String($(this).val()));
941 saveChatDebounced();
942 });
800}943}
801944
802/**945/**
@@ -819,16 +962,18 @@ export function removeReasoningFromString(str) {
819 * @property {string} reasoning Reasoning block962 * @property {string} reasoning Reasoning block
820 * @property {string} content Message content963 * @property {string} content Message content
821 * @param {string} str Content of the message964 * @param {string} str Content of the message
965 * @param {Object} options Optional arguments
966 * @param {boolean} [options.strict=true] Whether the reasoning block **has** to be at the beginning of the provided string (excluding whitespaces), or can be anywhere in it
822 * @returns {ParsedReasoning|null} Parsed reasoning block and message content967 * @returns {ParsedReasoning|null} Parsed reasoning block and message content
823 */968 */
824function parseReasoningFromString(str) {969function parseReasoningFromString(str, { strict = true } = {}) {
825 // Both prefix and suffix must be defined970 // Both prefix and suffix must be defined
826 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {971 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
827 return null;972 return null;
828 }973 }
829974
830 try {975 try {
831 const regex = new RegExp(`${escapeRegex(power_user.reasoning.prefix)}(.*?)${escapeRegex(power_user.reasoning.suffix)}`, 's');976 const regex = new RegExp(`${(strict ? '^\\s*?' : '')}${escapeRegex(power_user.reasoning.prefix)}(.*?)${escapeRegex(power_user.reasoning.suffix)}`, 's');
832977
833 let didReplace = false;978 let didReplace = false;
834 let reasoning = '';979 let reasoning = '';
@@ -838,9 +983,9 @@ function parseReasoningFromString(str) {
838 return '';983 return '';
839 });984 });
840985
841 if (didReplace && power_user.trim_spaces) {986 if (didReplace) {
842 reasoning = reasoning.trim();987 reasoning = trimSpaces(reasoning);
843 content = content.trim();988 content = trimSpaces(content);
844 }989 }
845990
846 return { reasoning, content };991 return { reasoning, content };
@@ -851,7 +996,7 @@ function parseReasoningFromString(str) {
851}996}
852997
853function registerReasoningAppEvents() {998function registerReasoningAppEvents() {
854 eventSource.makeFirst(event_types.MESSAGE_RECEIVED, (/** @type {number} */ idx) => {999 const eventHandler = (/** @type {number} */ idx) => {
855 if (!power_user.reasoning.auto_parse) {1000 if (!power_user.reasoning.auto_parse) {
856 return;1001 return;
857 }1002 }
@@ -869,6 +1014,11 @@ function registerReasoningAppEvents() {
869 return null;1014 return null;
870 }1015 }
8711016
1017 if (message.extra?.reasoning) {
1018 console.debug('[Reasoning] Message already has reasoning', idx);
1019 return null;
1020 }
1021
872 const parsedReasoning = parseReasoningFromString(message.mes);1022 const parsedReasoning = parseReasoningFromString(message.mes);
8731023
874 // No reasoning block found1024 // No reasoning block found
@@ -886,6 +1036,7 @@ function registerReasoningAppEvents() {
886 // If reasoning was found, add it to the message1036 // If reasoning was found, add it to the message
887 if (parsedReasoning.reasoning) {1037 if (parsedReasoning.reasoning) {
888 message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING);1038 message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING);
1039 message.extra.reasoning_type = ReasoningType.Parsed;
889 }1040 }
8901041
891 // Update the message text if it was changed1042 // Update the message text if it was changed
@@ -901,7 +1052,11 @@ function registerReasoningAppEvents() {
901 updateMessageBlock(idx, message);1052 updateMessageBlock(idx, message);
902 }1053 }
903 }1054 }
904 });1055 };
1056
1057 for (const event of [event_types.MESSAGE_RECEIVED, event_types.MESSAGE_UPDATED]) {
1058 eventSource.on(event, eventHandler);
1059 }
905}1060}
9061061
907export function initReasoning() {1062export function initReasoning() {
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+1 -0
@@ -34,6 +34,7 @@ export const enumIcons = {
34 preset: '⚙️',34 preset: '⚙️',
35 file: '📄',35 file: '📄',
36 message: '💬',36 message: '💬',
37 reasoning: '💡',
37 voice: '🎤',38 voice: '🎤',
38 server: '🖥️',39 server: '🖥️',
39 popup: '🗔',40 popup: '🗔',
public/scripts/textgen-settings.js+2 -2
@@ -311,7 +311,7 @@ export function validateTextGenUrl() {
311 const formattedUrl = formatTextGenURL(url);311 const formattedUrl = formatTextGenURL(url);
312312
313 if (!formattedUrl) {313 if (!formattedUrl) {
314 toastr.error('Enter a valid API URL', 'Text Completion API');314 toastr.error(t`Enter a valid API URL`, 'Text Completion API');
315 return;315 return;
316 }316 }
317317
@@ -1187,7 +1187,7 @@ export function getTextGenModel() {
1187 return settings.aphrodite_model;1187 return settings.aphrodite_model;
1188 case OLLAMA:1188 case OLLAMA:
1189 if (!settings.ollama_model) {1189 if (!settings.ollama_model) {
1190 toastr.error('No Ollama model selected.', 'Text Completion API');1190 toastr.error(t`No Ollama model selected.`, 'Text Completion API');
1191 throw new Error('No Ollama model selected');1191 throw new Error('No Ollama model selected');
1192 }1192 }
1193 return settings.ollama_model;1193 return settings.ollama_model;
public/scripts/tokenizers.js+4 -1
@@ -679,7 +679,7 @@ export function getTokenizerModel() {
679 }679 }
680680
681 if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) {681 if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) {
682 if (oai_settings.perplexity_model.includes('sonar-reasoning')) {682 if (oai_settings.perplexity_model.includes('sonar-reasoning') || oai_settings.perplexity_model.includes('r1-1776')) {
683 return deepseekTokenizer;683 return deepseekTokenizer;
684 }684 }
685 if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) {685 if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) {
@@ -694,6 +694,9 @@ export function getTokenizerModel() {
694 }694 }
695695
696 if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) {696 if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) {
697 if (oai_settings.groq_model.includes('qwen')) {
698 return qwen2Tokenizer;
699 }
697 if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) {700 if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) {
698 return llama3Tokenizer;701 return llama3Tokenizer;
699 }702 }
public/scripts/user.js+26 -0
@@ -9,6 +9,9 @@ import { ensureImageFormatSupported, getBase64Async, humanFileSize } from './uti
9export let currentUser = null;9export let currentUser = null;
10export let accountsEnabled = false;10export let accountsEnabled = false;
1111
12// Extend the session every 30 minutes
13const SESSION_EXTEND_INTERVAL = 30 * 60 * 1000;
14
12/**15/**
13 * Enable or disable user account controls in the UI.16 * Enable or disable user account controls in the UI.
14 * @param {boolean} isEnabled User account controls enabled17 * @param {boolean} isEnabled User account controls enabled
@@ -894,6 +897,24 @@ async function slugify(text) {
894 }897 }
895}898}
896899
900/**
901 * Pings the server to extend the user session.
902 */
903async function extendUserSession() {
904 try {
905 const response = await fetch('/api/ping?extend=1', {
906 method: 'GET',
907 headers: getRequestHeaders(),
908 });
909
910 if (!response.ok) {
911 throw new Error('Ping did not succeed', { cause: response.status });
912 }
913 } catch (error) {
914 console.error('Failed to extend user session', error);
915 }
916}
917
897jQuery(() => {918jQuery(() => {
898 $('#logout_button').on('click', () => {919 $('#logout_button').on('click', () => {
899 logout();920 logout();
@@ -904,4 +925,9 @@ jQuery(() => {
904 $('#account_button').on('click', () => {925 $('#account_button').on('click', () => {
905 openUserProfile();926 openUserProfile();
906 });927 });
928 setInterval(async () => {
929 if (currentUser) {
930 await extendUserSession();
931 }
932 }, SESSION_EXTEND_INTERVAL);
907});933});
public/scripts/utils.js+24 -6
@@ -8,7 +8,7 @@ import {
8import { getContext } from './extensions.js';8import { getContext } from './extensions.js';
9import { characters, getRequestHeaders, this_chid } from '../script.js';9import { characters, getRequestHeaders, this_chid } from '../script.js';
10import { isMobile } from './RossAscends-mods.js';10import { isMobile } from './RossAscends-mods.js';
11import { collapseNewlines } from './power-user.js';11import { collapseNewlines, power_user } from './power-user.js';
12import { debounce_timeout } from './constants.js';12import { debounce_timeout } from './constants.js';
13import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';13import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
14import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';14import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
@@ -677,6 +677,19 @@ export function sortByCssOrder(a, b) {
677}677}
678678
679/**679/**
680 * Trims leading and trailing whitespace from the input string based on a configuration setting.
681 * @param {string} input - The string to be trimmed
682 * @returns {string} The trimmed string if trimming is enabled; otherwise, returns the original string
683 */
684
685export function trimSpaces(input) {
686 if (!input || typeof input !== 'string') {
687 return input;
688 }
689 return power_user.trim_spaces ? input.trim() : input;
690}
691
692/**
680 * Trims a string to the end of a nearest sentence.693 * Trims a string to the end of a nearest sentence.
681 * @param {string} input The string to trim.694 * @param {string} input The string to trim.
682 * @returns {string} The trimmed string.695 * @returns {string} The trimmed string.
@@ -994,13 +1007,18 @@ export function getImageSizeFromDataURL(dataUrl) {
994 });1007 });
995}1008}
9961009
997export function getCharaFilename(chid) {1010/**
1011 * Gets the filename of the character avatar without extension
1012 * @param {number?} [chid=null] - Character ID. If not provided, uses the current character ID
1013 * @param {object} [options={}] - Options arguments
1014 * @param {string?} [options.manualAvatarKey=null] - Manually take the following avatar key, instead of using the chid to determine the name
1015 * @returns {string?} The filename of the character avatar without extension, or null if the character ID is invalid
1016 */
1017export function getCharaFilename(chid = null, { manualAvatarKey = null } = {}) {
998 const context = getContext();1018 const context = getContext();
999 const fileName = context.characters[chid ?? context.characterId]?.avatar;1019 const fileName = manualAvatarKey ?? context.characters[chid ?? context.characterId]?.avatar;
10001020
1001 if (fileName) {1021 return fileName?.replace(/\.[^/.]+$/, '') ?? null;
1002 return fileName.replace(/\.[^/.]+$/, '');
1003 }
1004}1022}
10051023
1006/**1024/**
public/style.css+23 -21
@@ -55,6 +55,10 @@
55 --interactable-outline-color: var(--white100);55 --interactable-outline-color: var(--white100);
56 --interactable-outline-color-faint: var(--white20a);56 --interactable-outline-color-faint: var(--white20a);
5757
58 --reasoning-body-color: var(--SmartThemeEmColor);
59 --reasoning-em-color: color-mix(in srgb, var(--SmartThemeEmColor) 67%, var(--SmartThemeBlurTintColor) 33%);
60 --reasoning-saturation: 0.5;
61
5862
59 /*Default Theme, will be changed by ToolCool Color Picker*/63 /*Default Theme, will be changed by ToolCool Color Picker*/
60 --SmartThemeBodyColor: rgb(220, 220, 210);64 --SmartThemeBodyColor: rgb(220, 220, 210);
@@ -348,13 +352,13 @@ input[type='checkbox']:focus-visible {
348352
349.mes_reasoning {353.mes_reasoning {
350 display: block;354 display: block;
351 border-left: 2px solid var(--SmartThemeEmColor);355 border-left: 2px solid var(--reasoning-body-color);
352 border-radius: 2px;356 border-radius: 2px;
353 padding: 5px;357 padding: 5px;
354 padding-left: 14px;358 padding-left: 14px;
355 margin-bottom: 0.5em;359 margin-bottom: 0.5em;
356 overflow-y: auto;360 overflow-y: auto;
357 color: var(--SmartThemeEmColor);361 color: hsl(from var(--reasoning-body-color) h calc(s * var(--reasoning-saturation)) l);
358}362}
359363
360.mes_reasoning_details {364.mes_reasoning_details {
@@ -374,18 +378,6 @@ input[type='checkbox']:focus-visible {
374 margin-bottom: 0;378 margin-bottom: 0;
375}379}
376380
377.mes_reasoning em,
378.mes_reasoning i,
379.mes_reasoning u,
380.mes_reasoning q,
381.mes_reasoning blockquote {
382 filter: saturate(0.5);
383}
384
385.mes_reasoning_details .mes_reasoning em {
386 color: color-mix(in srgb, var(--SmartThemeEmColor) 67%, var(--SmartThemeBlurTintColor) 33%);
387}
388
389.mes_reasoning_header_block {381.mes_reasoning_header_block {
390 flex-grow: 1;382 flex-grow: 1;
391}383}
@@ -438,7 +430,7 @@ input[type='checkbox']:focus-visible {
438}430}
439431
440/** If hidden reasoning should not be shown, we hide all blocks that don't have content */432/** If hidden reasoning should not be shown, we hide all blocks that don't have content */
441#chat:not([data-show-hidden-reasoning="true"]) .mes:has(.mes_reasoning:empty) .mes_reasoning_details {433#chat:not([data-show-hidden-reasoning="true"]):not(:has(.reasoning_edit_textarea)) .mes:has(.mes_reasoning:empty) .mes_reasoning_details {
442 display: none;434 display: none;
443}435}
444436
@@ -461,26 +453,36 @@ input[type='checkbox']:focus-visible {
461}453}
462454
463.mes_text i,455.mes_text i,
464.mes_text em,456.mes_text em {
457 color: var(--SmartThemeEmColor);
458}
465.mes_reasoning i,459.mes_reasoning i,
466.mes_reasoning em {460.mes_reasoning em {
467 color: var(--SmartThemeEmColor);461 color: hsl(from var(--reasoning-em-color) h calc(s * var(--reasoning-saturation)) l);
468}462}
469463
470.mes_text q i,464.mes_text q i,
471.mes_text q em {465.mes_text q em {
472 color: inherit;466 color: inherit;
473}467}
468.mes_reasoning q i,
469.mes_reasoning q em {
470 color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
471}
474472
475.mes_text u,473.mes_text u {
476.mes_reasoning u {
477 color: var(--SmartThemeUnderlineColor);474 color: var(--SmartThemeUnderlineColor);
478}475}
476.mes_reasoning u {
477 color: hsl(from var(--SmartThemeUnderlineColor) h calc(s * var(--reasoning-saturation)) l);
478}
479479
480.mes_text q,480.mes_text q {
481.mes_reasoning q {
482 color: var(--SmartThemeQuoteColor);481 color: var(--SmartThemeQuoteColor);
483}482}
483.mes_reasoning q {
484 color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
485}
484486
485.mes_text font[color] em,487.mes_text font[color] em,
486.mes_text font[color] i,488.mes_text font[color] i,
server.js+20 -5
@@ -58,6 +58,7 @@ import {
58import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';58import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
59import basicAuthMiddleware from './src/middleware/basicAuth.js';59import basicAuthMiddleware from './src/middleware/basicAuth.js';
60import whitelistMiddleware from './src/middleware/whitelist.js';60import whitelistMiddleware from './src/middleware/whitelist.js';
61import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
61import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';62import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
62import initRequestProxy from './src/request-proxy.js';63import initRequestProxy from './src/request-proxy.js';
63import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';64import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
@@ -243,7 +244,6 @@ const cliArguments = yargs(hideBin(process.argv))
243 describe: 'Request proxy URL (HTTP or SOCKS protocols)',244 describe: 'Request proxy URL (HTTP or SOCKS protocols)',
244 }).option('requestProxyBypass', {245 }).option('requestProxyBypass', {
245 type: 'array',246 type: 'array',
246 default: null,
247 describe: 'Request proxy bypass list (space separated list of hosts)',247 describe: 'Request proxy bypass list (space separated list of hosts)',
248 }).parseSync();248 }).parseSync();
249249
@@ -340,9 +340,17 @@ const CORS = cors({
340340
341app.use(CORS);341app.use(CORS);
342342
343if (listen && basicAuthMode) app.use(basicAuthMiddleware);343if (listen && basicAuthMode) {
344 app.use(basicAuthMiddleware);
345}
344346
345app.use(whitelistMiddleware(enableWhitelist, listen));347if (enableWhitelist) {
348 app.use(whitelistMiddleware());
349}
350
351if (listen) {
352 app.use(accessLoggerMiddleware());
353}
346354
347if (enableCorsProxy) {355if (enableCorsProxy) {
348 app.use(bodyParser.json({356 app.use(bodyParser.json({
@@ -556,7 +564,13 @@ app.use('/api/users', usersPublicRouter);
556564
557// Everything below this line requires authentication565// Everything below this line requires authentication
558app.use(requireLoginMiddleware);566app.use(requireLoginMiddleware);
559app.get('/api/ping', (_, response) => response.sendStatus(204));567app.get('/api/ping', (request, response) => {
568 if (request.query.extend && request.session) {
569 request.session.touch = Date.now();
570 }
571
572 response.sendStatus(204);
573});
560574
561// File uploads575// File uploads
562app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));576app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
@@ -754,6 +768,7 @@ const preSetupTasks = async function () {
754 await checkForNewContent(directories);768 await checkForNewContent(directories);
755 await ensureThumbnailCache();769 await ensureThumbnailCache();
756 cleanUploads();770 cleanUploads();
771 migrateAccessLog();
757772
758 await settingsInit();773 await settingsInit();
759 await statsInit();774 await statsInit();
@@ -856,7 +871,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
856 if (listen) {871 if (listen) {
857 console.log();872 console.log();
858 console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');873 console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
859 console.log('Check the "access.log" file in the SillyTavern directory to inspect incoming connections.');874 console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath()));
860 }875 }
861 console.log('\n' + getSeparator(plainGoToLog.length) + '\n');876 console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
862 console.log(goToLog);877 console.log(goToLog);
src/endpoints/backends/chat-completions.js+1 -4
@@ -979,6 +979,7 @@ router.post('/generate', jsonParser, function (request, response) {
979 headers = { ...OPENROUTER_HEADERS };979 headers = { ...OPENROUTER_HEADERS };
980 bodyParams = {980 bodyParams = {
981 'transforms': getOpenRouterTransforms(request),981 'transforms': getOpenRouterTransforms(request),
982 'include_reasoning': Boolean(request.body.include_reasoning),
982 };983 };
983984
984 if (request.body.min_p !== undefined) {985 if (request.body.min_p !== undefined) {
@@ -1004,10 +1005,6 @@ router.post('/generate', jsonParser, function (request, response) {
1004 bodyParams['route'] = 'fallback';1005 bodyParams['route'] = 'fallback';
1005 }1006 }
10061007
1007 if (request.body.include_reasoning) {
1008 bodyParams['include_reasoning'] = true;
1009 }
1010
1011 let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1);1008 let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1);
1012 if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && request.body.model?.startsWith('anthropic/claude-3')) {1009 if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && request.body.model?.startsWith('anthropic/claude-3')) {
1013 cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth);1010 cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth);
src/endpoints/extensions.js+1 -1
@@ -230,7 +230,7 @@ router.post('/version', jsonParser, async (request, response) => {
230 } catch (error) {230 } catch (error) {
231 // it is not a git repo, or has no commits yet, or is a bare repo231 // it is not a git repo, or has no commits yet, or is a bare repo
232 // not possible to update it, most likely can't get the branch name either232 // not possible to update it, most likely can't get the branch name either
233 return response.send({ currentBranchName: null, currentCommitHash, isUpToDate: true, remoteUrl: null });233 return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' });
234 }234 }
235235
236 const currentBranch = await git.cwd(extensionPath).branch();236 const currentBranch = await git.cwd(extensionPath).branch();
src/endpoints/sprites.js+13 -5
@@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
125 .map((file) => {125 .map((file) => {
126 const pathToSprite = path.join(spritesPath, file);126 const pathToSprite = path.join(spritesPath, file);
127 const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);127 const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
128
129 const fileName = path.parse(pathToSprite).name.toLowerCase();
130 // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
131 // Examples: joy.png, joy-1.png, joy.expressive.png
132 const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
133
128 return {134 return {
129 label: path.parse(pathToSprite).name.toLowerCase(),135 label: label,
130 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),136 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
131 };137 };
132 });138 });
@@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) {
141router.post('/delete', jsonParser, async (request, response) => {147router.post('/delete', jsonParser, async (request, response) => {
142 const label = request.body.label;148 const label = request.body.label;
143 const name = request.body.name;149 const name = request.body.name;
150 const spriteName = request.body.spriteName || label;
144151
145 if (!label || !name) {152 if (!spriteName || !name) {
146 return response.sendStatus(400);153 return response.sendStatus(400);
147 }154 }
148155
@@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => {
158165
159 // Remove existing sprite with the same label166 // Remove existing sprite with the same label
160 for (const file of files) {167 for (const file of files) {
161 if (path.parse(file).name === label) {168 if (path.parse(file).name === spriteName) {
162 fs.rmSync(path.join(spritesPath, file));169 fs.rmSync(path.join(spritesPath, file));
163 }170 }
164 }171 }
@@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => {
221 const file = request.file;228 const file = request.file;
222 const label = request.body.label;229 const label = request.body.label;
223 const name = request.body.name;230 const name = request.body.name;
231 const spriteName = request.body.spriteName || label;
224232
225 if (!file || !label || !name) {233 if (!file || !label || !name) {
226 return response.sendStatus(400);234 return response.sendStatus(400);
@@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => {
243251
244 // Remove existing sprite with the same label252 // Remove existing sprite with the same label
245 for (const file of files) {253 for (const file of files) {
246 if (path.parse(file).name === label) {254 if (path.parse(file).name === spriteName) {
247 fs.rmSync(path.join(spritesPath, file));255 fs.rmSync(path.join(spritesPath, file));
248 }256 }
249 }257 }
250258
251 const filename = label + path.parse(file.originalname).ext;259 const filename = spriteName + path.parse(file.originalname).ext;
252 const spritePath = path.join(file.destination, file.filename);260 const spritePath = path.join(file.destination, file.filename);
253 const pathToFile = path.join(spritesPath, filename);261 const pathToFile = path.join(spritesPath, filename);
254 // Copy uploaded file to sprites folder262 // Copy uploaded file to sprites folder
src/endpoints/users-public.js+10 -7
@@ -3,13 +3,16 @@ import crypto from 'node:crypto';
3import storage from 'node-persist';3import storage from 'node-persist';
4import express from 'express';4import express from 'express';
5import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';5import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
6import { jsonParser, getIpFromRequest } from '../express-common.js';6import { jsonParser, getIpFromRequest, getRealIpFromHeader } from '../express-common.js';
7import { color, Cache, getConfigValue } from '../util.js';7import { color, Cache, getConfigValue } from '../util.js';
8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';
99
10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);
11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false);
11const MFA_CACHE = new Cache(5 * 60 * 1000);12const MFA_CACHE = new Cache(5 * 60 * 1000);
1213
14const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
15
13export const router = express.Router();16export const router = express.Router();
14const loginLimiter = new RateLimiterMemory({17const loginLimiter = new RateLimiterMemory({
15 points: 5,18 points: 5,
@@ -60,7 +63,7 @@ router.post('/login', jsonParser, async (request, response) => {
60 return response.status(400).json({ error: 'Missing required fields' });63 return response.status(400).json({ error: 'Missing required fields' });
61 }64 }
6265
63 const ip = getIpFromRequest(request);66 const ip = getIpAddress(request);
64 await loginLimiter.consume(ip);67 await loginLimiter.consume(ip);
6568
66 /** @type {import('../users.js').User} */69 /** @type {import('../users.js').User} */
@@ -92,7 +95,7 @@ router.post('/login', jsonParser, async (request, response) => {
92 return response.json({ handle: user.handle });95 return response.json({ handle: user.handle });
93 } catch (error) {96 } catch (error) {
94 if (error instanceof RateLimiterRes) {97 if (error instanceof RateLimiterRes) {
95 console.error('Login failed: Rate limited from', getIpFromRequest(request));98 console.error('Login failed: Rate limited from', getIpAddress(request));
96 return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });99 return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
97 }100 }
98101
@@ -108,7 +111,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => {
108 return response.status(400).json({ error: 'Missing required fields' });111 return response.status(400).json({ error: 'Missing required fields' });
109 }112 }
110113
111 const ip = getIpFromRequest(request);114 const ip = getIpAddress(request);
112 await recoverLimiter.consume(ip);115 await recoverLimiter.consume(ip);
113116
114 /** @type {import('../users.js').User} */117 /** @type {import('../users.js').User} */
@@ -132,7 +135,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => {
132 return response.sendStatus(204);135 return response.sendStatus(204);
133 } catch (error) {136 } catch (error) {
134 if (error instanceof RateLimiterRes) {137 if (error instanceof RateLimiterRes) {
135 console.error('Recover step 1 failed: Rate limited from', getIpFromRequest(request));138 console.error('Recover step 1 failed: Rate limited from', getIpAddress(request));
136 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });139 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
137 }140 }
138141
@@ -150,7 +153,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => {
150153
151 /** @type {import('../users.js').User} */154 /** @type {import('../users.js').User} */
152 const user = await storage.getItem(toKey(request.body.handle));155 const user = await storage.getItem(toKey(request.body.handle));
153 const ip = getIpFromRequest(request);156 const ip = getIpAddress(request);
154157
155 if (!user) {158 if (!user) {
156 console.error('Recover step 2 failed: User', request.body.handle, 'not found');159 console.error('Recover step 2 failed: User', request.body.handle, 'not found');
@@ -186,7 +189,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => {
186 return response.sendStatus(204);189 return response.sendStatus(204);
187 } catch (error) {190 } catch (error) {
188 if (error instanceof RateLimiterRes) {191 if (error instanceof RateLimiterRes) {
189 console.error('Recover step 2 failed: Rate limited from', getIpFromRequest(request));192 console.error('Recover step 2 failed: Rate limited from', getIpAddress(request));
190 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });193 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
191 }194 }
192195
src/endpoints/vectors.js+11 -11
@@ -132,35 +132,35 @@ function getSourceSettings(source, request) {
132 switch (source) {132 switch (source) {
133 case 'togetherai':133 case 'togetherai':
134 return {134 return {
135 model: String(request.headers['x-togetherai-model']),135 model: String(request.body.model),
136 };136 };
137 case 'openai':137 case 'openai':
138 return {138 return {
139 model: String(request.headers['x-openai-model']),139 model: String(request.body.model),
140 };140 };
141 case 'cohere':141 case 'cohere':
142 return {142 return {
143 model: String(request.headers['x-cohere-model']),143 model: String(request.body.model),
144 };144 };
145 case 'llamacpp':145 case 'llamacpp':
146 return {146 return {
147 apiUrl: String(request.headers['x-llamacpp-url']),147 apiUrl: String(request.body.apiUrl),
148 };148 };
149 case 'vllm':149 case 'vllm':
150 return {150 return {
151 apiUrl: String(request.headers['x-vllm-url']),151 apiUrl: String(request.body.apiUrl),
152 model: String(request.headers['x-vllm-model']),152 model: String(request.body.model),
153 };153 };
154 case 'ollama':154 case 'ollama':
155 return {155 return {
156 apiUrl: String(request.headers['x-ollama-url']),156 apiUrl: String(request.body.apiUrl),
157 model: String(request.headers['x-ollama-model']),157 model: String(request.body.model),
158 keep: Boolean(request.headers['x-ollama-keep']),158 keep: Boolean(request.body.keep),
159 };159 };
160 case 'extras':160 case 'extras':
161 return {161 return {
162 extrasUrl: String(request.headers['x-extras-url']),162 extrasUrl: String(request.body.extrasUrl),
163 extrasKey: String(request.headers['x-extras-key']),163 extrasKey: String(request.body.extrasKey),
164 };164 };
165 case 'transformers':165 case 'transformers':
166 return {166 return {
src/express-common.js+14 -0
@@ -25,3 +25,17 @@ export function getIpFromRequest(req) {
25 }25 }
26 return clientIp;26 return clientIp;
27}27}
28
29/**
30 * Gets the IP address of the client when behind reverse proxy using x-real-ip header, falls back to socket remote address.
31 * This function should be used when the application is running behind a reverse proxy (e.g., Nginx, traefik, Caddy...).
32 * @param {import('express').Request} req Request object
33 * @returns {string} IP address of the client
34 */
35export function getRealIpFromHeader(req) {
36 if (req.headers['x-real-ip']) {
37 return req.headers['x-real-ip'].toString();
38 }
39
40 return getIpFromRequest(req);
41}
src/middleware/accessLogWriter.js+59 -0
@@ -0,0 +1,59 @@
1import path from 'node:path';
2import fs from 'node:fs';
3import { getRealIpFromHeader } from '../express-common.js';
4import { color, getConfigValue } from '../util.js';
5
6const enableAccessLog = getConfigValue('logging.enableAccessLog', true);
7
8const knownIPs = new Set();
9
10export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log');
11
12export function migrateAccessLog() {
13 try {
14 if (!fs.existsSync('access.log')) {
15 return;
16 }
17 const logPath = getAccessLogPath();
18 if (fs.existsSync(logPath)) {
19 return;
20 }
21 fs.renameSync('access.log', logPath);
22 console.log(color.yellow('Migrated access.log to new location:'), logPath);
23 } catch (e) {
24 console.error('Failed to migrate access log:', e);
25 console.info('Please move access.log to the data directory manually.');
26 }
27}
28
29/**
30 * Creates middleware for logging access and new connections
31 * @returns {import('express').RequestHandler}
32 */
33export default function accessLoggerMiddleware() {
34 return function (req, res, next) {
35 const clientIp = getRealIpFromHeader(req);
36 const userAgent = req.headers['user-agent'];
37
38 if (!knownIPs.has(clientIp)) {
39 // Log new connection
40 console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
41 knownIPs.add(clientIp);
42
43 // Write to access log if enabled
44 if (enableAccessLog) {
45 const logPath = getAccessLogPath();
46 const timestamp = new Date().toISOString();
47 const log = `${timestamp} ${clientIp} ${userAgent}\n`;
48
49 fs.appendFile(logPath, log, (err) => {
50 if (err) {
51 console.error('Failed to write access log:', err);
52 }
53 });
54 }
55 }
56
57 next();
58 };
59}
src/middleware/webpack-serve.js+9 -5
@@ -1,11 +1,8 @@
1import path from 'node:path';1import path from 'node:path';
2import webpack from 'webpack';2import webpack from 'webpack';
3import { publicLibConfig } from '../../webpack.config.js';3import getPublicLibConfig from '../../webpack.config.js';
44
5export default function getWebpackServeMiddleware() {5export default function getWebpackServeMiddleware() {
6 const outputPath = publicLibConfig.output?.path;
7 const outputFile = publicLibConfig.output?.filename;
8
9 /**6 /**
10 * A very spartan recreation of webpack-dev-middleware.7 * A very spartan recreation of webpack-dev-middleware.
11 * @param {import('express').Request} req Request object.8 * @param {import('express').Request} req Request object.
@@ -14,6 +11,10 @@ export default function getWebpackServeMiddleware() {
14 * @type {import('express').RequestHandler}11 * @type {import('express').RequestHandler}
15 */12 */
16 function devMiddleware(req, res, next) {13 function devMiddleware(req, res, next) {
14 const publicLibConfig = getPublicLibConfig();
15 const outputPath = publicLibConfig.output?.path;
16 const outputFile = publicLibConfig.output?.filename;
17
17 if (req.method === 'GET' && path.parse(req.path).base === outputFile) {18 if (req.method === 'GET' && path.parse(req.path).base === outputFile) {
18 return res.sendFile(outputFile, { root: outputPath });19 return res.sendFile(outputFile, { root: outputPath });
19 }20 }
@@ -23,9 +24,12 @@ export default function getWebpackServeMiddleware() {
2324
24 /**25 /**
25 * Wait until Webpack is done compiling.26 * Wait until Webpack is done compiling.
27 * @param {object} param Parameters.
28 * @param {boolean} [param.forceDist] Whether to force the use the /dist folder.
26 * @returns {Promise<void>}29 * @returns {Promise<void>}
27 */30 */
28 devMiddleware.runWebpackCompiler = () => {31 devMiddleware.runWebpackCompiler = ({ forceDist = false } = {}) => {
32 const publicLibConfig = getPublicLibConfig(forceDist);
29 const compiler = webpack(publicLibConfig);33 const compiler = webpack(publicLibConfig);
3034
31 return new Promise((resolve) => {35 return new Promise((resolve) => {
src/middleware/whitelist.js+11 -20
@@ -10,7 +10,6 @@ import { color, getConfigValue, safeReadFileSync } from '../util.js';
10const whitelistPath = path.join(process.cwd(), './whitelist.txt');10const whitelistPath = path.join(process.cwd(), './whitelist.txt');
11const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);11const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
12let whitelist = getConfigValue('whitelist', []);12let whitelist = getConfigValue('whitelist', []);
13let knownIPs = new Set();
1413
15if (fs.existsSync(whitelistPath)) {14if (fs.existsSync(whitelistPath)) {
16 try {15 try {
@@ -48,47 +47,39 @@ function getForwardedIp(req) {
4847
49/**48/**
50 * Returns a middleware function that checks if the client IP is in the whitelist.49 * Returns a middleware function that checks if the client IP is in the whitelist.
51 * @param {boolean} whitelistMode If whitelist mode is enabled via config or command line
52 * @param {boolean} listen If listen mode is enabled via config or command line
53 * @returns {import('express').RequestHandler} The middleware function50 * @returns {import('express').RequestHandler} The middleware function
54 */51 */
55export default function whitelistMiddleware(whitelistMode, listen) {52export default function whitelistMiddleware() {
56 const forbiddenWebpage = Handlebars.compile(53 const forbiddenWebpage = Handlebars.compile(
57 safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',54 safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
58 );55 );
5956
57 const noLogPaths = [
58 '/favicon.ico',
59 ];
60
60 return function (req, res, next) {61 return function (req, res, next) {
61 const clientIp = getIpFromRequest(req);62 const clientIp = getIpFromRequest(req);
62 const forwardedIp = getForwardedIp(req);63 const forwardedIp = getForwardedIp(req);
63 const userAgent = req.headers['user-agent'];64 const userAgent = req.headers['user-agent'];
6465
65 if (listen && !knownIPs.has(clientIp)) {
66 console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
67 knownIPs.add(clientIp);
68
69 // Write access log
70 const timestamp = new Date().toISOString();
71 const log = `${timestamp} ${clientIp} ${userAgent}\n`;
72 fs.appendFile('access.log', log, (err) => {
73 if (err) {
74 console.error('Failed to write access log:', err);
75 }
76 });
77 }
78
79 //clientIp = req.connection.remoteAddress.split(':').pop();66 //clientIp = req.connection.remoteAddress.split(':').pop();
80 if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))67 if (!whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))
81 || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))68 || forwardedIp && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
82 ) {69 ) {
83 // Log the connection attempt with real IP address70 // Log the connection attempt with real IP address
84 const ipDetails = forwardedIp71 const ipDetails = forwardedIp
85 ? `${clientIp} (forwarded from ${forwardedIp})`72 ? `${clientIp} (forwarded from ${forwardedIp})`
86 : clientIp;73 : clientIp;
74
75 if (!noLogPaths.includes(req.path)) {
87 console.warn(76 console.warn(
88 color.red(77 color.red(
89 `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`,78 `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`,
90 ),79 ),
91 );80 );
81 }
82
92 return res.status(403).send(forbiddenWebpage({ ipDetails }));83 return res.status(403).send(forbiddenWebpage({ ipDetails }));
93 }84 }
94 next();85 next();
src/plugin-loader.js+2 -2
@@ -3,7 +3,7 @@ import path from 'node:path';
3import url from 'node:url';3import url from 'node:url';
44
5import express from 'express';5import express from 'express';
6import { default as git } from 'simple-git';6import { default as git, CheckRepoActions } from 'simple-git';
7import { sync as commandExistsSync } from 'command-exists';7import { sync as commandExistsSync } from 'command-exists';
8import { getConfigValue, color } from './util.js';8import { getConfigValue, color } from './util.js';
99
@@ -256,7 +256,7 @@ async function updatePlugins(pluginsPath) {
256 const pluginPath = path.join(pluginsPath, directory);256 const pluginPath = path.join(pluginsPath, directory);
257 const pluginRepo = git(pluginPath);257 const pluginRepo = git(pluginPath);
258258
259 const isRepo = await pluginRepo.checkIsRepo();259 const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
260 if (!isRepo) {260 if (!isRepo) {
261 continue;261 continue;
262 }262 }
src/util.js+1 -1
@@ -763,7 +763,7 @@ export function stringToBool(str) {
763 * Setup the minimum log level763 * Setup the minimum log level
764 */764 */
765export function setupLogLevel() {765export function setupLogLevel() {
766 const logLevel = getConfigValue('minLogLevel', LOG_LEVELS.DEBUG);766 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG);
767767
768 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};768 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};
769 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};769 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};
webpack.config.js+41 -4
@@ -1,13 +1,49 @@
1import process from 'node:process';1import process from 'node:process';
2import path from 'node:path';2import path from 'node:path';
3import isDocker from 'is-docker';
34
4/** @type {import('webpack').Configuration} */5/**
5export const publicLibConfig = {6 * Get the Webpack configuration for the public/lib.js file.
7 * 1. Docker has got cache and the output file pre-baked.
8 * 2. Non-Docker environments use the global DATA_ROOT variable to determine the cache and output directories.
9 * @param {boolean} forceDist Whether to force the use the /dist folder.
10 * @returns {import('webpack').Configuration}
11 * @throws {Error} If the DATA_ROOT variable is not set.
12 * */
13export default function getPublicLibConfig(forceDist = false) {
14 function getCacheDirectory() {
15 if (forceDist || isDocker()) {
16 return path.resolve(process.cwd(), 'dist/webpack');
17 }
18
19 if (typeof globalThis.DATA_ROOT === 'string') {
20 return path.resolve(globalThis.DATA_ROOT, '_webpack', 'cache');
21 }
22
23 throw new Error('DATA_ROOT variable is not set.');
24 }
25
26 function getOutputDirectory() {
27 if (forceDist || isDocker()) {
28 return path.resolve(process.cwd(), 'dist');
29 }
30
31 if (typeof globalThis.DATA_ROOT === 'string') {
32 return path.resolve(globalThis.DATA_ROOT, '_webpack', 'output');
33 }
34
35 throw new Error('DATA_ROOT variable is not set.');
36 }
37
38 const cacheDirectory = getCacheDirectory();
39 const outputDirectory = getOutputDirectory();
40
41 return {
6 mode: 'production',42 mode: 'production',
7 entry: './public/lib.js',43 entry: './public/lib.js',
8 cache: {44 cache: {
9 type: 'filesystem',45 type: 'filesystem',
10 cacheDirectory: path.resolve(process.cwd(), 'dist/webpack'),46 cacheDirectory: cacheDirectory,
11 store: 'pack',47 store: 'pack',
12 compression: 'gzip',48 compression: 'gzip',
13 },49 },
@@ -28,8 +64,9 @@ export const publicLibConfig = {
28 hints: false,64 hints: false,
29 },65 },
30 output: {66 output: {
31 path: path.resolve(process.cwd(), 'dist'),67 path: outputDirectory,
32 filename: 'lib.js',68 filename: 'lib.js',
33 libraryTarget: 'module',69 libraryTarget: 'module',
34 },70 },
35 };71 };
72}