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, +2141 -1268Ignore whitespace
Dockerfile+1 -1
@@ -4,7 +4,7 @@ FROM node:lts-alpine3.19
44ARG APP_HOME=/home/node/app
55
66# Install system dependencies
77RUN apk add --no-cache gcompat tini git
88
99# Create app directory
1010WORKDIR ${APP_HOME}
default/!DO-NOT-EDIT-THESE-FILES.txt+13 -0
@@ -0,0 +1,13 @@
1+These are master copies of the default content files and are managed by SillyTavern.
2+
3+Editing any of these files would not only have no effect, but will also cause merge conflicts during update pulls.
4+
5+You should edit their respective copies instead, for example:
6+
7+1. /default/config.yaml => /config.yaml
8+2. /default/public/css/user.css => /public/css/user.css
9+etc.
10+
11+Any questions? You're always welcome at our official documentation website:
12+
13+https://docs.sillytavern.app/
default/config.yaml+12 -2
@@ -71,8 +71,6 @@ autheliaAuth: false
7171# the username and passwords for basic auth are the same as those
7272# for the individual accounts
7373perUserBasicAuth: false
74-# Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3)
75-minLogLevel: 0
7674
7775# User session timeout *in seconds* (defaults to 24 hours).
7876## Set to a positive number to expire session after a certain time of inactivity
@@ -85,6 +83,18 @@ cookieSecret: ''
8583disableCsrfProtection: false
8684# Disable startup security checks - NOT RECOMMENDED
8785securityOverride: false
86+# -- LOGGING CONFIGURATION --
87+logging:
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 --
94+rateLimiting:
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
8898# -- ADVANCED CONFIGURATION --
8999# Open the browser automatically
90100autorun: true
docker/build-lib.js+1 -1
@@ -1,4 +1,4 @@
11import getWebpackServeMiddleware from '../src/middleware/webpack-serve.js';
22
33const middleware = getWebpackServeMiddleware();
44await middleware.runWebpackCompiler({ forceDist: true });
package-lock.json+45 -22
@@ -28,7 +28,7 @@
2828 "cors": "^2.8.5",
2929 "csrf-sync": "^4.0.3",
3030 "diff-match-patch": "^1.0.5",
3131 "dompurify": "^3.12.74",
3232 "droll": "^0.2.1",
3333 "express": "^4.21.0",
3434 "form-data": "^4.0.0",
@@ -43,6 +43,7 @@
4343 "ip-matching": "^2.1.2",
4444 "ip-regex": "^5.0.0",
4545 "ipaddr.js": "^2.0.1",
46+ "is-docker": "^3.0.0",
4647 "jimp": "^0.22.10",
4748 "localforage": "^1.10.0",
4849 "lodash": "^4.17.21",
@@ -87,7 +88,6 @@
8788 "@types/cookie-session": "^2.0.49",
8889 "@types/cors": "^2.8.17",
8990 "@types/deno": "^2.0.0",
90- "@types/dompurify": "^3.0.5",
9191 "@types/express": "^4.17.21",
9292 "@types/jquery": "^3.5.29",
9393 "@types/jquery-cropper": "^1.0.4",
@@ -1181,16 +1181,6 @@
11811181 "dev": true,
11821182 "license": "MIT"
11831183 },
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- },
11941184 "node_modules/@types/estree": {
11951185 "version": "1.0.6",
11961186 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -1478,8 +1468,8 @@
14781468 "version": "2.0.7",
14791469 "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
14801470 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
14811471 "devlicense": true"MIT",
14821472 "licenseoptional": "MIT"true
14831473 },
14841474 "node_modules/@types/write-file-atomic": {
14851475 "version": "4.0.3",
@@ -3236,10 +3226,13 @@
32363226 }
32373227 },
32383228 "node_modules/dompurify": {
32393229 "version": "3.12.74",
32403230 "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.12.74.tgz",
32413231 "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTaysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+eNpxSl5gmzn+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQMs/mg==",
32423232 "license": "(MPL-2.0 OR Apache-2.0)",
3233+ "optionalDependencies": {
3234+ "@types/trusted-types": "^2.0.7"
3235+ }
32433236 },
32443237 "node_modules/domutils": {
32453238 "version": "3.1.0",
@@ -4657,15 +4650,15 @@
46574650 "license": "MIT"
46584651 },
46594652 "node_modules/is-docker": {
46604653 "version": "23.20.10",
46614654 "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-23.20.10.tgz",
46624655 "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQeljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
46634656 "license": "MIT",
46644657 "bin": {
46654658 "is-docker": "cli.js"
46664659 },
46674660 "engines": {
4668- "node": ">=8"
4661+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
46694662 },
46704663 "funding": {
46714664 "url": "https://github.com/sponsors/sindresorhus"
@@ -4742,6 +4735,21 @@
47424735 "node": ">=8"
47434736 }
47444737 },
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+ },
47454753 "node_modules/isarray": {
47464754 "version": "1.0.0",
47474755 "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -5526,6 +5534,21 @@
55265534 "url": "https://github.com/sponsors/sindresorhus"
55275535 }
55285536 },
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+ },
55295552 "node_modules/openai": {
55305553 "version": "4.17.4",
55315554 "resolved": "https://registry.npmjs.org/openai/-/openai-4.17.4.tgz",
package.json+2 -2
@@ -18,7 +18,7 @@
1818 "cors": "^2.8.5",
1919 "csrf-sync": "^4.0.3",
2020 "diff-match-patch": "^1.0.5",
2121 "dompurify": "^3.12.74",
2222 "droll": "^0.2.1",
2323 "express": "^4.21.0",
2424 "form-data": "^4.0.0",
@@ -33,6 +33,7 @@
3333 "ip-matching": "^2.1.2",
3434 "ip-regex": "^5.0.0",
3535 "ipaddr.js": "^2.0.1",
36+ "is-docker": "^3.0.0",
3637 "jimp": "^0.22.10",
3738 "localforage": "^1.10.0",
3839 "lodash": "^4.17.21",
@@ -116,7 +117,6 @@
116117 "@types/cookie-session": "^2.0.49",
117118 "@types/cors": "^2.8.17",
118119 "@types/deno": "^2.0.0",
119- "@types/dompurify": "^3.0.5",
120120 "@types/express": "^4.17.21",
121121 "@types/jquery": "^3.5.29",
122122 "@types/jquery-cropper": "^1.0.4",
plugins.js+2 -2
@@ -8,7 +8,7 @@ import path from 'node:path';
88import process from 'node:process';
99import { fileURLToPath } from 'node:url';
1010
1111import { default as git, CheckRepoActions } from 'simple-git';
1212import { color } from './src/util.js';
1313
1414const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -49,7 +49,7 @@ async function updatePlugins() {
4949 const pluginPath = path.join(pluginsPath, directory);
5050 const pluginRepo = git(pluginPath);
5151
5252 const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
5353 if (!isRepo) {
5454 console.log(`Directory ${color.yellow(directory)} is not a Git repository`);
5555 continue;
post-install.js+5 -0
@@ -104,6 +104,11 @@ const keyMigrationMap = [
104104 newKey: 'extensions.models.textToSpeech',
105105 migrate: (value) => value,
106106 },
107+ {
108+ oldKey: 'minLogLevel',
109+ newKey: 'logging.minLogLevel',
110+ migrate: (value) => value,
111+ },
107112];
108113
109114/**
public/global.d.ts+8 -0
@@ -40,4 +40,12 @@ declare global {
4040 searchInputCssClass?: string;
4141 }
4242 }
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>;
4351}
public/index.html+36 -20
@@ -2000,7 +2000,7 @@
20002000 </span>
20012001 </div>
20022002 </div>
20032003 <div class="range-block" data-source="deepseek,openrouter,custom">
20042004 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
20052005 <input id="openai_show_thoughts" type="checkbox" />
20062006 <span>
@@ -2015,7 +2015,7 @@
20152015 </div>
20162016 </div>
20172017 <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom">
20182018 <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.">
20192019 <label for="openai_reasoning_effort" data-i18n="Reasoning Effort">
20202020 Reasoning Effort
20212021 </label>
@@ -3186,21 +3186,33 @@
31863186 </div>
31873187 <h4 data-i18n="Groq Model">Groq Model</h4>
31883188 <select id="model_groq_select">
31893189 <optgroup label="ProductionAlibaba ModelsCloud">
31903190 <option value="gemma2qwen-9b2.5-it32b">gemma2qwen-9b2.5-it32b</option>
31913191 <option value="llamaqwen-32.35-70bcoder-versatile32b">llamaqwen-32.35-70bcoder-versatile32b</option>
3192- <option value="llama-3.1-8b-instant">llama-3.1-8b-instant</option>
3192+ </optgroup>
3193- <option value="llama3-70b-8192">llama3-70b-8192</option>
3193+ <optgroup label="DeepSeek / Alibaba Cloud">
31943194 <option value="llama3deepseek-8br1-8192distill-qwen-32b">llama3deepseek-8br1-8192distill-qwen-32b</option>
3195- <option value="mixtral-8x7b-32768">mixtral-8x7b-32768</option>
31963195 </optgroup>
31973196 <optgroup label="PreviewDeepSeek Models/ Meta">
31983197 <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>
3200- <option value="llama-3.2-1b-preview">llama-3.2-1b-preview</option>
3199+ <optgroup label="Google">
32013200 <option value="llama-3.2gemma2-3b9b-previewit">llama-3.2gemma2-3b9b-previewit</option>
3202- <option value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview</option>
3201+ </optgroup>
3203- <option value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview</option>
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>
3205+ <option value="llama-3.2-1b-preview">llama-3.2-1b-preview </option>
3206+ <option value="llama-3.2-3b-preview">llama-3.2-3b-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>
32043216 </optgroup>
32053217 </select>
32063218 </div>
@@ -3253,6 +3265,10 @@
32533265 <option value="sonar">sonar</option>
32543266 <option value="sonar-pro">sonar-pro</option>
32553267 <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>
32563272 </optgroup>
32573273 <optgroup label="Deprecated Models">
32583274 <!-- These are scheduled for deprecation after 2/22/2025 -->
@@ -4001,7 +4017,7 @@
40014017 <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">
40024018 <small>
40034019 <span data-i18n="Max Recursion Steps">Max Recursion Steps</span>
40044020 <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>
40054021 </small>
40064022 <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">
40074023 <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 @@
44134429 <input id="prefer_character_jailbreak" type="checkbox" />
44144430 <small data-i18n="Prefer Character Card Instructions">Prefer Char. Instructions</small>
44154431 </label>
44164432 <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]Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip">
44174433 <input id="never_resize_avatars" type="checkbox" />
44184434 <small data-i18n="Never resize avatars">Never resize avatars</small>
44194435 </label>
@@ -4666,7 +4682,7 @@
46664682 <small data-i18n="Enabled">Enabled</small>
46674683 </label>
46684684 <small data-i18n="Minimum generated message length">Minimum generated message length</small>
46694685 <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 thisthese many characters, trigger an auto-swipe." data-i18n="[title]If the generated message is shorter than thisthese many characters, trigger an auto-swipe">
46704686 <small data-i18n="Blacklisted words">Blacklisted words</small>
46714687 <div class="auto_swipe">
46724688 <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 @@
68906906 </div>
68916907 <div id="form_sheld">
68926908 <div id="dialogue_del_mes">
68936909 <div id="dialogue_del_mes_ok" data-i18n="Delete" class="menu_button">Delete</div>
68946910 <div id="dialogue_del_mes_cancel" data-i18n="Cancel" class="menu_button">Cancel</div>
68956911 </div>
68966912 <div id="send_form" class="no-connection">
68976913 <form id="file_form" class="wide100p displayNone">
public/lib/eventemitter.js+43 -2
@@ -24,10 +24,22 @@ if (typeof Array.prototype.indexOf === 'function') {
2424
2525
2626/* Polyfill EventEmitter. */
27-var EventEmitter = function () {
27+/**
28+ * Creates an event emitter.
29+ * @param {string[]} autoFireAfterEmit Auto-fire event names
30+ */
31+var EventEmitter = function (autoFireAfterEmit = []) {
2832 this.events = {};
33+ this.autoFireLastArgs = new Map();
34+ this.autoFireAfterEmit = new Set(autoFireAfterEmit);
2935};
3036
37+/**
38+ * Adds a listener to an event.
39+ * @param {string} event Event name
40+ * @param {function} listener Event listener
41+ * @returns
42+ */
3143EventEmitter.prototype.on = function (event, listener) {
3244 // Unknown event used by external libraries?
3345 if (event === undefined) {
@@ -40,6 +52,10 @@ EventEmitter.prototype.on = function (event, listener) {
4052 }
4153
4254 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+ }
4359};
4460
4561/**
@@ -60,6 +76,10 @@ EventEmitter.prototype.makeLast = function (event, listener) {
6076 }
6177
6278 events.push(listener);
79+
80+ if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
81+ listener.apply(this, this.autoFireLastArgs.get(event));
82+ }
6383}
6484
6585/**
@@ -80,8 +100,17 @@ EventEmitter.prototype.makeFirst = function (event, listener) {
80100 }
81101
82102 events.unshift(listener);
103+
104+ if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
105+ listener.apply(this, this.autoFireLastArgs.get(event));
106+ }
83107}
84108
109+/**
110+ * Removes a listener from an event.
111+ * @param {string} event Event name
112+ * @param {function} listener Event listener
113+ */
85114EventEmitter.prototype.removeListener = function (event, listener) {
86115 var idx;
87116
@@ -94,6 +123,10 @@ EventEmitter.prototype.removeListener = function (event, listener) {
94123 }
95124};
96125
126+/**
127+ * Emits an event with optional arguments.
128+ * @param {string} event Event name
129+ */
97130EventEmitter.prototype.emit = async function (event) {
98131 let args = [].slice.call(arguments, 1);
99132 if (localStorage.getItem('eventTracing') === 'true') {
@@ -118,6 +151,10 @@ EventEmitter.prototype.emit = async function (event) {
118151 }
119152 }
120153 }
154+
155+ if (this.autoFireAfterEmit.has(event)) {
156+ this.autoFireLastArgs.set(event, args);
157+ }
121158};
122159
123160EventEmitter.prototype.emitAndWait = function (event) {
@@ -144,10 +181,14 @@ EventEmitter.prototype.emitAndWait = function (event) {
144181 }
145182 }
146183 }
184+
185+ if (this.autoFireAfterEmit.has(event)) {
186+ this.autoFireLastArgs.set(event, args);
187+ }
147188};
148189
149190EventEmitter.prototype.once = function (event, listener) {
150191 this.on(event, function g () {
151192 this.removeListener(event, g);
152193 listener.apply(this, arguments);
153194 });
public/locales/ar-sa.json+2 -2
@@ -633,7 +633,7 @@
633633 "Prefer Character Card Prompt": "تفضيل التعليمات من بطاقة الشخصية",
634634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "إذا تم التحقق وكانت بطاقة الشخصية تحتوي على تجاوز للكسر (تعليمات تاريخ المشاركة)، استخدم ذلك بدلاً من ذلك",
635635 "Prefer Character Card Jailbreak": "تفضيل كسر الحصار من بطاقة الشخصية",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "تجنب اقتصاص صور الأحرف المستوردة وتغيير حجمها. عند إيقاف التشغيل، قم بالقص/تغيير الحجم إلى 512 × 768.",
637637 "Never resize avatars": "لا تغيير حجم الصور الرمزية أبدًا",
638638 "Show actual file names on the disk, in the characters list display only": "عرض الأسماء الفعلية للملفات على القرص، في عرض قائمة الشخصيات فقط",
639639 "Show avatar filenames": "عرض أسماء ملفات الصور الرمزية",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "السحب التلقائي",
710710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "تمكين وظيفة السحب التلقائي. الإعدادات في هذا القسم تؤثر فقط عند تمكين السحب التلقائي",
711711 "Minimum generated message length": "الحد الأدنى لطول الرسالة المولدة",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "إذا كانت الرسالة المولدة أقصر من هذا، فتحريض السحب التلقائي",
713713 "Blacklisted words": "الكلمات الممنوعة",
714714 "words you dont want generated separated by comma ','": "الكلمات التي لا تريد توليدها مفصولة بفاصلة ','",
715715 "Blacklisted word count to swipe": "عدد الكلمات الممنوعة للسحب",
public/locales/de-de.json+2 -2
@@ -633,7 +633,7 @@
633633 "Prefer Character Card Prompt": "Bevorzuge Charakterkarten-Prompt",
634634 "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",
635635 "Prefer Character Card Jailbreak": "Bevorzuge Charakterkarten-Jailbreak",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_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.",
637637 "Never resize avatars": "Avatare niemals verkleinern",
638638 "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",
639639 "Show avatar filenames": "Avatar-Dateinamen anzeigen",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "Automatisches Wischen",
710710 "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",
711711 "Minimum generated message length": "Minimale generierte Nachrichtenlänge",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Wenn die generierte Nachricht kürzer ist als diese, löse automatisches Wischen aus",
713713 "Blacklisted words": "Verbotene Wörter",
714714 "words you dont want generated separated by comma ','": "Wörter, die du nicht generiert haben möchtest, durch Komma ',' getrennt",
715715 "Blacklisted word count to swipe": "Anzahl der verbotenen Wörter, um zu wischen",
public/locales/es-es.json+2 -2
@@ -633,7 +633,7 @@
633633 "Prefer Character Card Prompt": "Preferir Indicaciones en Tarjeta de Personaje",
634634 "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",
635635 "Prefer Character Card Jailbreak": "Preferir Jailbreak en Tarjeta de Personaje",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_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.",
637637 "Never resize avatars": "Nunca redimensionar avatares",
638638 "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",
639639 "Show avatar filenames": "Mostrar nombres de archivo de avatares",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "Deslizamiento automático",
710710 "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",
711711 "Minimum generated message length": "Longitud mínima del mensaje generado",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Si el mensaje generado es más corto que esto, activar un deslizamiento automático",
713713 "Blacklisted words": "Palabras prohibidas",
714714 "words you dont want generated separated by comma ','": "palabras que no desea generar separadas por coma ','",
715715 "Blacklisted word count to swipe": "Número de palabras prohibidas para deslizar",
public/locales/fr-fr.json+3 -4
@@ -581,7 +581,7 @@
581581 "Advanced Character Search": "Recherche de personnage avancée",
582582 "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",
583583 "Prefer Character Card Prompt": "Préférer le prompt du personnage",
584584 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_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.",
585585 "Never resize avatars": "Ne jamais redimensionner les avatars",
586586 "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",
587587 "Show avatar filenames": "Afficher les noms de fichier des avatars",
@@ -656,7 +656,7 @@
656656 "Auto-swipe": "Balayage automatique",
657657 "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é",
658658 "Minimum generated message length": "Longueur minimale du message généré",
659659 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Si le message généré est plus court que cela, déclenchez un balayage automatique",
660660 "Blacklisted words": "Mots en liste noire",
661661 "words you dont want generated separated by comma ','": "mots que vous ne voulez pas générer séparés par des virgules ','",
662662 "Blacklisted word count to swipe": "Nombre de mots en liste noire pour balayer",
@@ -1485,7 +1485,7 @@
14851485 "(disabled when max recursion steps are used)": "(désactivé lorsque le nombre maximum de pas de récursivité est utilisé)",
14861486 "Cap the number of entry activation recursions": "Plafonner le nombre de récursions d'activation d'entrée",
14871487 "Max Recursion Steps": "Nombre maximal d'étapes de récursivité",
14881488 "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)",
14891489 "Include names with each message into the context for scanning": "Inclure les noms dans chaque message dans le contexte pour l'analyse.",
14901490 "Apply current sorting as Order": "Appliquer le tri actuel comme ordre",
14911491 "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 @@
16021602 "Character Expressions": "Expressions de personnages",
16031603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",
16041604 "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)",
16061605 "Classifier API": "API de classification",
16071606 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",
16081607 "Main API": "API principale",
public/locales/is-is.json+2 -2
@@ -633,7 +633,7 @@
633633 "Prefer Character Card Prompt": "Kosstu kvenkortu fyrirspurn",
634634 "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",
635635 "Prefer Character Card Jailbreak": "Kosstu kvenkortu fangabrot",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "Forðastu að klippa og breyta stærð innfluttra stafamynda. Þegar slökkt er á því skaltu skera/breyta stærð í 512x768.",
637637 "Never resize avatars": "Aldrei breyta stærðinni á merkjum",
638638 "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",
639639 "Show avatar filenames": "Sýna nöfn merkja",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "Sjálfvirkur sveip",
710710 "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",
711711 "Minimum generated message length": "Lágmarks lengd á mynduðum skilaboðum",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Ef mynduðu skilaboðin eru styttri en þessi, kallaðu fram sjálfvirkar sveiflugerðar",
713713 "Blacklisted words": "Svört orð",
714714 "words you dont want generated separated by comma ','": "orð sem þú vilt ekki að framleiða aðskilin með kommu ','",
715715 "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 @@
633633 "Prefer Character Card Prompt": "Preferisci Prompt della Scheda Personaggio",
634634 "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",
635635 "Prefer Character Card Jailbreak": "Preferisci Jailbreak della Scheda Personaggio",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "Evita di ritagliare e ridimensionare le immagini dei personaggi importati. Quando è disattivato, ritaglia/ridimensiona a 512x768.",
637637 "Never resize avatars": "Non ridimensionare mai gli avatar",
638638 "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",
639639 "Show avatar filenames": "Mostra nomi file avatar",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "Auto-swipe",
710710 "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",
711711 "Minimum generated message length": "Lunghezza minima del messaggio generato",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Se il messaggio generato è più breve di questo, attiva un'automatica rimozione",
713713 "Blacklisted words": "Parole in blacklist",
714714 "words you dont want generated separated by comma ','": "parole che non vuoi generate separate da virgola ','",
715715 "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 @@
633633 "Prefer Character Card Prompt": "キャラクターカードのプロンプトを優先",
634634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "チェックされていてキャラクターカードにジェイルブレイクオーバーライド(投稿履歴指示)が含まれている場合、それを代わりに使用します",
635635 "Prefer Character Card Jailbreak": "キャラクターカードのJailbreakを優先",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "インポートした文字画像の切り取りやサイズ変更を避けます。オフにすると、512x768 に切り取り/サイズ変更されます。",
637637 "Never resize avatars": "アバターを常にリサイズしない",
638638 "Show actual file names on the disk, in the characters list display only": "ディスク上の実際のファイル名を表示します。キャラクターリストの表示にのみ",
639639 "Show avatar filenames": "アバターのファイル名を表示",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "オートスワイプ",
710710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "自動スワイプ機能を有効にします。このセクションの設定は、自動スワイプが有効になっている場合にのみ効果があります",
711711 "Minimum generated message length": "生成されたメッセージの最小長",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "生成されたメッセージがこれよりも短い場合、自動スワイプをトリガーします",
713713 "Blacklisted words": "ブラックリストされた単語",
714714 "words you dont want generated separated by comma ','": "コンマ ',' で区切られた生成したくない単語",
715715 "Blacklisted word count to swipe": "スワイプするブラックリストされた単語の数",
public/locales/ko-kr.json+2 -3
@@ -646,7 +646,7 @@
646646 "Prefer Character Card Prompt": "캐릭터 카드 프롬프트 선호",
647647 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "선택되어 있고 캐릭터 카드에 (Post-History 지시)탈옥 재정의가 포함 된 경우, 그것을 대신 사용합니다.",
648648 "Prefer Character Card Jailbreak": "캐릭터 카드 탈옥 선호",
649649 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "가져온 캐릭터 이미지를 자르거나 크기를 조정하지 마세요. 꺼져 있으면 512x768로 자르거나 크기를 조정합니다.",
650650 "Never resize avatars": "아바타 크기 변경하지 않음",
651651 "Show actual file names on the disk, in the characters list display only": "실제 파일 이름을 디스크에 표시하며 캐릭터 목록 디스플레이에만",
652652 "Show avatar filenames": "아바타 파일 이름 표시",
@@ -724,7 +724,7 @@
724724 "Auto-swipe": "자동 스와이프",
725725 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "자동 스와이프 기능을 활성화합니다. 이 섹션의 설정은 자동 스와이프가 활성화되었을 때만 영향을 미칩니다",
726726 "Minimum generated message length": "생성된 메시지 최소 길이",
727727 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "생성된 메시지가이보다 짧으면 자동 스와이프를 트리거합니다",
728728 "Blacklisted words": "금지어",
729729 "words you dont want generated separated by comma ','": "쉼표로 구분된 생성하지 않으려는 단어",
730730 "Blacklisted word count to swipe": "스와이프할 금지어 개수",
@@ -1467,7 +1467,6 @@
14671467 "menu within": "내의 메뉴",
14681468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",
14691469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",
1470- "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)",
14711470 "Classifier API": "분류를 위한 API",
14721471 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",
14731472 "Local": "로컬",
public/locales/nl-nl.json+2 -2
@@ -633,7 +633,7 @@
633633 "Prefer Character Card Prompt": "Voorkeur karakterkaart prompt",
634634 "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",
635635 "Prefer Character Card Jailbreak": "Voorkeur karakterkaart jailbreak",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "Vermijd het bijsnijden en vergroten/verkleinen van geïmporteerde karakterafbeeldingen. Indien uitgeschakeld, bijsnijden/formaat wijzigen naar 512 x 768.",
637637 "Never resize avatars": "Avatars nooit verkleinen",
638638 "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",
639639 "Show avatar filenames": "Toon avatar bestandsnamen",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "Automatisch vegen",
710710 "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",
711711 "Minimum generated message length": "Minimale gegenereerde berichtlengte",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Als het gegenereerde bericht korter is dan dit, activeer dan een automatische veeg",
713713 "Blacklisted words": "Verboden woorden",
714714 "words you dont want generated separated by comma ','": "woorden die je niet gegenereerd wilt hebben gescheiden door komma ','",
715715 "Blacklisted word count to swipe": "Aantal verboden woorden om te vegen",
public/locales/pt-pt.json+2 -2
@@ -633,7 +633,7 @@
633633 "Prefer Character Card Prompt": "Preferir Prompt do Cartão de Personagem",
634634 "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",
635635 "Prefer Character Card Jailbreak": "Preferir Jailbreak do Cartão de Personagem",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "Evite cortar e redimensionar imagens de personagens importados. Quando desativado, corte/redimensione para 512x768.",
637637 "Never resize avatars": "Nunca redimensionar avatares",
638638 "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",
639639 "Show avatar filenames": "Mostrar nomes de arquivo de avatar",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "Auto-swipe",
710710 "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",
711711 "Minimum generated message length": "Comprimento mínimo da mensagem gerada",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Se a mensagem gerada for mais curta que isso, acione um auto-swipe",
713713 "Blacklisted words": "Palavras proibidas",
714714 "words you dont want generated separated by comma ','": "palavras que você não quer geradas separadas por vírgula ','",
715715 "Blacklisted word count to swipe": "Contagem de palavras proibidas para swipe",
public/locales/ru-ru.json+74 -14
@@ -195,7 +195,7 @@
195195 "Yes": "Да",
196196 "No": "Нет",
197197 "Context %": "Процент контекста",
198198 "Budget Cap": "БюджетныйЛимит лимитбюджета",
199199 "(0 = disabled)": "(0 = отключено)",
200200 "None": "Отсутствует",
201201 "User Settings": "Настройки пользователя",
@@ -426,7 +426,7 @@
426426 "Requests logprobs from the API for the Token Probabilities feature": "Запросить логпробы из API для функции Token Probabilities.",
427427 "Automatically reject and re-generate AI message based on configurable criteria": "Автоматическое отклонение и повторная генерация сообщений AI на основе настраиваемых критериев.",
428428 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Включить авто-свайп. Настройки в этом разделе действуют только при включенном авто-свайпе.",
429429 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Если сгенерированное сообщение короче этого значения, срабатывает авто-свайп.",
430430 "Reload and redraw the currently open chat": "Перезагрузить и перерисовать открытый в данный момент чат.",
431431 "Auto-Expand Message Actions": "Развернуть действия",
432432 "Persona Management": "Управление персоной",
@@ -575,10 +575,10 @@
575575 "Characters sorting order": "Порядок сортировки персонажей",
576576 "Remove": "Убрать",
577577 "Select a World Info file for": "Выбрать файл с миром для",
578578 "Primary Lorebook": "ОсновногоОсновной лорбукалорбук",
579579 "A selected World Info will be bound to this character as its own Lorebook.": "Информация о мире будет привязана к персонажу как его собственный лорбук.",
580580 "When generating an AI reply, it will be combined with the entries from a global World Info selector.": "Когда ИИ генерирует ответ, он будет совмещён с записями из глобально выбранного мира.",
581581 "Exporting a character would also export the selected Lorebook file embedded in the JSON data.": "При экспорте персонажа вместе с ним также выгрузится выбранный лорбук в виде JSON.",
582582 "Additional Lorebooks": "Вспомогательные лорбуки",
583583 "Associate one or more auxillary Lorebooks with this character.": "Привязать к этому персонажу один или больше вспомогательных лорбуков",
584584 "NOTE: These choices are optional and won't be preserved on character export!": "ВНИМАНИЕ: эти выборы необязательные и не будут сохранены при экспорте персонажа!",
@@ -593,7 +593,7 @@
593593 "Prompt": "Промпт",
594594 "Copy": "Скопировать",
595595 "Confirm": "Подтвердить",
596596 "Copy this message": "СкопироватьПродублировать сообщение",
597597 "Delete this message": "Удалить сообщение",
598598 "Move message up": "Переместить сообщение вверх",
599599 "Move message down": "Переместить сообщение вниз",
@@ -612,7 +612,7 @@
612612 "Ask AI to write your message for you": "Попросить ИИ написать сообщение за вас",
613613 "Continue the last message": "Продолжить текущее сообщение",
614614 "Bind user name to that avatar": "Закрепить имя за этим аватаром",
615615 "Select this as default persona for the new chats.": "ВыбератьВыбирать эту Персону в качестве персоныперсону по умолчанию для всех новых чатов.",
616616 "Change persona image": "Сменить аватар персоны",
617617 "Delete persona": "Удалить персону",
618618 "Reduced Motion": "Сокращение анимаций",
@@ -640,7 +640,7 @@
640640 "Token Probabilities": "Вероятности токенов",
641641 "Close chat": "Закрыть чат",
642642 "Manage chat files": "Все чаты",
643643 "Import Extension From Git Repo": "Импортировать расширение из Git Repository-репозитория.",
644644 "Install extension": "Установить расширение",
645645 "Manage extensions": "Управление расширениями",
646646 "Tokens persona description": "Токенов",
@@ -995,7 +995,7 @@
995995 "Set your custom avatar.": "Установить аватарку",
996996 "Remove your custom avatar.": "Сбросить аватарку",
997997 "Make a Snapshot": "Сделать снимок",
998998 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "Не менять размер картинок у импортируемых персонажей. При отключении все картинки будут приводиться к размеру 512х768",
999999 "Char List Subheader": "Доп. заголовок в списке персонажей",
10001000 "# Messages to Load": "Сколько сообщений загружать",
10011001 "(0 = All)": "(0 = все)",
@@ -1122,7 +1122,7 @@
11221122 "help_hotkeys_0": "Горячие клавиши",
11231123 "You can browse a list of bundled characters in the": "Комплектных персонажей можно найти в меню",
11241124 "Download Extensions & Assets": "Загрузить расширения и ресурсы",
11251125 "menu within": "внутри этихв кубиковменю",
11261126 "Assets URL": "URL с описанием ресурсов",
11271127 "Custom (OpenAI-compatible)": "Кастомный (совместимый с OpenAI)",
11281128 "Custom Endpoint (Base URL)": "Кастомный эндпоинт (базовый URL)",
@@ -1943,7 +1943,7 @@
19431943 "and connect to an": "и подключитесь к",
19441944 "You can add more": "Можете добавить больше",
19451945 "from other websites": "с других сайтов.",
19461946 "Go to the": "ЗаглянитеЗаходите в",
19471947 "to install additional features.": ", чтобы установить разные дополнительные ресурсы.",
19481948 "or_welcome": "; также доступен",
19491949 "Claude API Key": "Ключ от API Claude",
@@ -1958,7 +1958,7 @@
19581958 "Save": "Сохранить",
19591959 "Chat Lorebook": "Лорбук для чата",
19601960 "chat_world_template_txt": "Выбранный мир будет привязан к этому чату. Будет добавляться в промпт наряду с глобальным лорбуком и лором персонажа.",
19611961 "world_button_title": "Лор персонажа\n\nНажмите, чтобы загрузить\nShift + кликЛКМ, чтобы открыть диалог привязки мира",
19621962 "No auxillary Lorebooks set. Click here to select.": "Вспомогательный лорбук не выбран. Нажмите, чтобы выбрать.",
19631963 "ext_regex_user_input_desc": "Отправленные вами сообщения.",
19641964 "ext_regex_ai_input_desc": "Полученные от API ответы.",
@@ -2144,5 +2144,65 @@
21442144 "Not connected to the API!": "Нет соединения с API!",
21452145 "ext_type_system": "Это комплектное расширение. Его нельзя удалить, а обновляется оно вместе со всей системой.",
21462146 "Update all": "Обновить все",
21472147 "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:": "Токенов:"
21482208}
public/locales/uk-ua.json+2 -2
@@ -633,7 +633,7 @@
633633 "Prefer Character Card Prompt": "Перевага запиту персонажа",
634634 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "Якщо відмічено і картка персонажа містить заміну джейлбрейку (Інструкцію), використовуйте її замість цього",
635635 "Prefer Character Card Jailbreak": "Перевага джейлбрейку персонажа",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "Уникайте обрізання та зміни розміру імпортованих зображень символів. Коли вимкнено, обрізати/змінити розмір до 512x768.",
637637 "Never resize avatars": "Ніколи не змінювати розмір аватарів",
638638 "Show actual file names on the disk, in the characters list display only": "Показувати фактичні назви файлів на диску, тільки у відображенні списку персонажів",
639639 "Show avatar filenames": "Показувати імена файлів аватарів",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "Автоматичний змах",
710710 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Вмикає функцію автоматичного змаху. Налаштування в цьому розділі діють лише тоді, коли увімкнено автоматичний змах",
711711 "Minimum generated message length": "Мінімальна довжина згенерованого повідомлення",
712712 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "Якщо згенероване повідомлення коротше за це, викликайте автоматичний змаху",
713713 "Blacklisted words": "Список заборонених слів",
714714 "words you dont want generated separated by comma ','": "слова, які ви не хочете генерувати, розділені комою ','",
715715 "Blacklisted word count to swipe": "Кількість заборонених слів для змаху",
public/locales/vi-vn.json+2 -2
@@ -633,7 +633,7 @@
633633 "Prefer Character Card Prompt": "Ưu tiên Gợi ý từ Card",
634634 "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 đó",
635635 "Prefer Character Card Jailbreak": "Ưu tiên Jailbreak từ Card",
636636 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_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.",
637637 "Never resize avatars": "Không bao giờ thay đổi kích thước hình đại diện",
638638 "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",
639639 "Show avatar filenames": "Hiển thị tên tệp hình đại diện",
@@ -709,7 +709,7 @@
709709 "Auto-swipe": "Tự động vuốt",
710710 "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",
711711 "Minimum generated message length": "Độ dài tối thiểu của tin nhắn được tạo",
712712 "If the generated message is shorter than thisthese 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",
713713 "Blacklisted words": "Từ trong danh sách đen",
714714 "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 ','",
715715 "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 @@
584584 "(0 = unlimited, use budget)": "(“0”为无限制,使用预算)",
585585 "Cap the number of entry activation recursions": "限制条目激活递归的次数",
586586 "Max Recursion Steps": "最大递归深度",
587587 "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(当使用最小激活次数时,此功能被禁用)",
588588 "Insertion Strategy": "插入策略",
589589 "Sorted Evenly": "均匀排序",
590590 "Character Lore First": "角色世界书优先",
@@ -722,7 +722,7 @@
722722 "Prefer Character Card Prompt": "角色卡提示词优先",
723723 "If checked and the character card contains a Post-History Instructions override, use that instead": "开启后,如果角色卡包含后历史指令覆盖,则使用它。",
724724 "Prefer Character Card Instructions": "首选角色卡说明",
725725 "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768never_resize_avatars_tooltip": "避免裁剪和调整导入的角色图像的大小。关闭时,裁剪/调整大小为 512x768。",
726726 "Never resize avatars": "永不调整头像大小",
727727 "Show actual file names on the disk, in the characters list display only": "在角色列表显示中,显示磁盘上实际的文件名。",
728728 "Show avatar filenames": "显示头像文件名",
@@ -804,7 +804,7 @@
804804 "Auto-swipe": "自动滑动",
805805 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "启用自动滑动功能。仅当启用自动滑动时,本节中的设置才会生效",
806806 "Minimum generated message length": "生成的消息的最小长度",
807807 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "如果生成的消息短于此长度,则触发自动滑动",
808808 "Blacklisted words": "屏蔽词",
809809 "words you dont want generated separated by comma ','": "不想生成的词语,用半角逗号“,”分隔",
810810 "Blacklisted word count to swipe": "触发滑动的黑名单词语数量",
@@ -1349,7 +1349,6 @@
13491349 "Character Expressions": "角色表情",
13501350 "Translate text to English before classification": "分类之前将文本翻译成英文",
13511351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",
1352- "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)",
13531352 "Classifier API": "分类器 API",
13541353 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
13551354 "Main API": "主要 API",
public/locales/zh-tw.json+199 -27
@@ -4,9 +4,9 @@
44 "Duplicate": "複製",
55 "Persona": "使用者角色",
66 "Delete": "刪除",
77 "AI Response Configuration": "設定 AI 回應回應設定",
88 "AI Configuration panel will stay open": "上鎖 = AI 設定面板將保持開啟",
99 "clickslidertips": "點選滑桿數字可手動輸入點擊滑桿旁的數字以手動輸入。",
1010 "MAD LAB MODE ON": "瘋狂實驗室模式",
1111 "Documentation on sampling parameters": "取樣參數的說明文件。",
1212 "kobldpresets": "Kobold 預設設定檔",
@@ -35,7 +35,7 @@
3535 "Only enable this if your model supports context sizes greater than 8192 tokens": "僅在您的模型支援超過 8192 個符元的上下文長度時啟用此功能",
3636 "Max prompt cost:": "最大提示詞費用:",
3737 "Display the response bit by bit as it is generated.": "逐字顯示生成中的回應內容。",
3838 "When this is off, responses will be displayed all at once when they are complete.": "關閉時,回應將在生成完成後一次性顯示回應將在生成完成後一次全部顯示。",
3939 "Temperature": "溫度",
4040 "rep.pen": "重複懲罰",
4141 "Rep. Pen. Range.": "重複懲罰範圍",
@@ -51,7 +51,7 @@
5151 "Aggressive": "積極",
5252 "Very aggressive": "非常積極",
5353 "Unlocked Context Size": "解鎖上下文長度",
5454 "Unrestricted maximum value for the context slider": "不限制上下文滑桿最大值不限制上下文長度的最大值",
5555 "Context Size (tokens)": "上下文長度(符元數)",
5656 "Max Response Length (tokens)": "最大回應長度(符元數)",
5757 "Multiple swipes per generation": "每次生成多次滑動",
@@ -71,7 +71,7 @@
7171 "Utility Prompts": "實用提示詞",
7272 "Impersonation prompt": "AI 扮演提示詞",
7373 "Restore default prompt": "還原預設提示詞",
7474 "Prompt that is used for Impersonation function": "用於 AI 模仿功能的提示詞扮演使用者」功能的提示詞",
7575 "World Info Format Template": "世界資訊格式",
7676 "Restore default format": "還原預設格式",
7777 "Wraps activated World Info entries before inserting into the prompt.": "在插入提示詞前包裝已啟用的世界資訊條目。",
@@ -79,7 +79,7 @@
7979 "scenario_format_template_part_2": "來標示要插入內容的位置。",
8080 "Scenario Format Template": "場景格式",
8181 "Personality Format Template": "個性格式",
8282 "Group Nudge Prompt Template": "群組推動提示詞範本群組聊天格式微調",
8383 "Sent at the end of the group chat history to force reply from a specific character.": "在群組聊天歷史結束時發送以強制特定角色回覆",
8484 "New Chat": "新聊天",
8585 "Restore new chat prompt": "還原新聊天的提示詞",
@@ -89,17 +89,17 @@
8989 "Set at the beginning of the chat history to indicate that a new group chat is about to start.": "設定在聊天歷史的開頭以表明即將開始新的群組聊天",
9090 "New Example Chat": "新範例聊天",
9191 "Set at the beginning of Dialogue examples to indicate that a new example chat is about to start.": "設定在對話範例的開頭以表明即將開始新的範例聊天",
9292 "Continue nudge": "繼續輔助提示詞繼續輔助微調",
9393 "Set at the end of the chat history when the continue button is pressed.": "按下繼續按鈕時設定在聊天歷史的末尾按下「繼續」按鈕時,插入於聊天歷史的末尾",
9494 "Replace empty message": "取代空白訊息",
9595 "Send this text instead of nothing when the text box is empty.": "當文字方塊為空時當文本框為空時發送此字串而不是空白發送此文本以取代空白。",
9696 "Seed": "種子",
9797 "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 可使用原始機率。",
9999 "Top_K_desc": "Top K 設定可以選擇的最高符元數量。\n例如,Top K 為 20,這意味著只保留排名前 20 的符元(無論它們的機率是多樣還是有限的)。\n設定為 0 以停用。",
100100 "Top_P_desc": "Top P(又名核心取樣)會將所有頂級符元加總,直到達到目標百分比。\n例如,如果前兩個符元都是 25%,而 Top P 設為 0.5,那麼只有前兩個符元會被考慮。\n設定為 1.0 以停用。",
101101 "Typical P": "Typical P",
102102 "Typical_P_desc": "Typical P 取樣根據符元偏離集合平均熵的程度進行優先排序。\n它會保留累積機率接近預設閾值(例如0.5)的符元,強調那些具有平均信息量的符元。\n設定為 1.0 以停用。",
103103 "Min_P_desc": "Min P 設定基本最小機率。\n這個值會根據最高符元的機率進行調整。例如,如果最高符元機率為 80%,而 Min P 設為 0.1 ,那麼只有機率高於 8% 的符元會被考慮。\n設定為 0 以停用。",
104104 "Top_A_desc": "Top A 根據最高符元機率的平方設定符元選擇的門檻。\n例如,如果 Top A 值為 0.2,而最高符元機率為 50%,那麼低於 5%(0.2 * 0.5^2) 的符元機率就會被排除。\n設定為 0 以停用。",
105105 "Tail_Free_Sampling_desc": "無尾取樣(Tail-Free Sampling, TFS)會透過分析符元機率的變化率(使用導數)來尋找分佈中的低機率符元尾部。\n它會根據標準化的二階導數,保留直到某個閾值(例如0.3)的符元。\n數值越接近 0 ,表示會棄去越多符元。設定為 1.0 以停用。",
@@ -126,10 +126,10 @@
126126 "Logit Bias": "Logit 偏差",
127127 "Add": "新增",
128128 "Helps to ban or reenforce the usage of certain words": "有助於禁止或強化某些符元的使用",
129129 "CFG Scale": "CFG 比例縮放比例",
130130 "Negative Prompt": "負面提示詞",
131131 "Add text here that would make the AI generate things you don't want in your outputs.": "在這裡新增文字在此新增文字使以防止 AI 生成您不希望在輸出中出現的內容在輸出中生成您不希望出現的內容。",
132132 "Used if CFG Scale is unset globally, per chat or character": "如果CFG Scale未在全域、每個聊天或角色中設定CFG 縮放比例未被全域設定則使用它將作用於所有聊天或角色",
133133 "Mirostat Tau": "Tau",
134134 "Mirostat LR": "Mirostat 學習率",
135135 "Min Length": "最小長度",
@@ -399,7 +399,7 @@
399399 "Applies additional processing to the prompt before sending it to the API.": "這個選項會在將提示詞送往 API 之前,對它進行額外的處理。",
400400 "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "透過發送簡短的測試訊息來驗證您的 API 連線。請注意,您將因此獲得榮譽!",
401401 "Test Message": "測試訊息",
402402 "Auto-connect to Last Server": "自動連線到上次伺服器自動連接至上次使用的伺服器",
403403 "Missing key": "❌ 鑰匙遺失",
404404 "Key saved": "✔️ 金鑰已儲存",
405405 "View hidden API keys": "查看隱藏的 API 金鑰",
@@ -634,7 +634,7 @@
634634 "Prefer Character Card Prompt": "角色卡主要提示詞優先",
635635 "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "如果選中並且角色卡包含越獄覆寫(聊天歷史後指示),則使用該提示詞。",
636636 "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。此設定會關閉上傳頭像時的裁剪彈出視窗。",
638638 "Never resize avatars": "永不調整頭像大小",
639639 "Show actual file names on the disk, in the characters list display only": "僅在角色列表顯示實際檔案名稱。",
640640 "Show avatar filenames": "顯示頭像檔案名",
@@ -710,7 +710,7 @@
710710 "Auto-swipe": "自動滑動",
711711 "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "啟用自動滑動功能。此部分的設定僅在啟用自動滑動時有效。",
712712 "Minimum generated message length": "生成訊息的最小長度",
713713 "If the generated message is shorter than thisthese many characters, trigger an auto-swipe": "如果生成的訊息比這個短,將觸發自動滑動。",
714714 "Blacklisted words": "黑名單詞語",
715715 "words you dont want generated separated by comma ','": "您不想生成的文字,使用逗號分隔",
716716 "Blacklisted word count to swipe": "滑動的黑名單詞語數量",
@@ -1008,7 +1008,7 @@
10081008 "prompt_manager_edit": "編輯",
10091009 "prompt_manager_name": "名稱",
10101010 "A name for this prompt.": "這個提示詞的名稱。",
10111011 "To whom this message will be attributed.": "此訊息將隸屬於誰此訊息所屬的角色。",
10121012 "AI Assistant": "人工智慧助手",
10131013 "prompt_manager_position": "位置",
10141014 "Injection position. Next to other prompts (relative) or in-chat (absolute).": "注入位置。與其他提示詞相鄰(相對位置)或在聊天中(絕對位置)。",
@@ -1458,7 +1458,6 @@
14581458 "Example: http://localhost:1234/v1": "例如:http://localhost:1234/v1",
14591459 "popup-button-crop": "裁剪",
14601460 "(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(使用最小啟動設定時將停用)",
14621461 "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)。",
14631462 "A multiplicative factor to expand the overall area that the nodes take up.": "節點佔用該擴充功能區域的倍數。",
14641463 "Abort current image generation task": "終止目前的圖片生成任務",
@@ -1653,7 +1652,6 @@
16531652 "HuggingFace Token": "HuggingFace 符元",
16541653 "Image Captioning": "圖片註解",
16551654 "Generate Caption": "產生圖片註解",
1656- "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)",
16571655 "Injection Position": "插入位置",
16581656 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
16591657 "Injection Template": "插入範本",
@@ -1708,7 +1706,7 @@
17081706 "Quick Impersonate button": "快速模擬按鈕",
17091707 "Recursion Level": "遞迴層級",
17101708 "Remove all image overrides": "移除所有圖片覆蓋",
17111709 "Restore default": "恢復預設",
17121710 "Retain#": "保留#",
17131711 "Retrieve chunks": "檢索 Chunks",
17141712 "Sampler Order": "取樣順序",
@@ -1741,7 +1739,7 @@
17411739 "Show group chat queue": "顯示群組聊天隊列",
17421740 "Size threshold (KB)": "大小閾值(KB)",
17431741 "Slash Command": "斜線命令",
17441742 "space_ slash command.": " 斜線命令。",
17451743 "Sprite Folder Override": "表情立繪資料夾覆蓋",
17461744 "Sprite set:": "立繪組:",
17471745 "Show Gallery": "查看圖庫",
@@ -1762,7 +1760,7 @@
17621760 "Threshold": "閾值",
17631761 "to install 3rd party extensions.": "用於安裝第三方擴充功能。",
17641762 "Top": "頂部",
17651763 "Translate text to English before classification": "在分類前將文本翻譯為英文。分類前,將訊息翻譯為英文",
17661764 "Uncheck to hide the extensions messages in chat prompts.": "不勾選即可隱藏聊天提示詞中的擴充功能訊息。",
17671765 "Unchecked: only entries with ❌ status can be activated.": "未勾選時:僅允許啟用狀態為 ❌ 的條目。",
17681766 "Unified Sampling": "統一取樣(Unified Sampling)",
@@ -1806,7 +1804,7 @@
18061804 "context_derived": "若可能,根據模型元數據推導。",
18071805 "instruct_derived": "若可能,根據模型元數據推導。",
18081806 "Inserted before the first User's message.": "插入於第一則使用者訊息之前。",
18091807 "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(啟用最小啟動次數時無效)",
18101808 "Quick 'Impersonate' button": "快速「AI 扮演使用者」按鈕",
18111809 "Manual": "手動",
18121810 "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 @@
18911889 "Narrate user messages": "朗讀使用者訊息",
18921890 "Auto Generation": "自動生成",
18931891 "Requires auto generation to be enabled.": "需要啟用自動生成功能。",
18941892 "Narrate by paragraphs (when streaming)": "按段落朗讀(使用「串流」傳輸時)",
18951893 "Only narrate quotes": "僅朗讀「引號」中的文字",
18961894 "Ignore text, even quotes, inside asterisk": "忽略 *(星號)內的文字(包括「引號」)",
18971895 "Narrate only the translated text": "僅朗讀翻譯後的文本",
@@ -2452,5 +2450,179 @@
24522450 "Modules provided by your Extras API:": "由您的 Extras API 提供的模組:",
24532451 "Not connected to the API!": "未連線到 API!",
24542452 "ext_type_system": "這是內建的擴充功能,無法刪除,且會跟隨系統更新。",
24552453 "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(桂乃芬)呈獻。"
24562628}
public/script.js+48 -6
@@ -366,6 +366,10 @@ DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
366366 return;
367367 }
368368
369+ if (!(node instanceof Element)) {
370+ return;
371+ }
372+
369373 let mediaBlocked = false;
370374
371375 switch (node.tagName) {
@@ -493,6 +497,7 @@ export const event_types = {
493497 // TODO: Naming convention is inconsistent with other events
494498 CHARACTER_DELETED: 'characterDeleted',
495499 CHARACTER_DUPLICATED: 'character_duplicated',
500+ CHARACTER_RENAMED: 'character_renamed',
496501 /** @deprecated The event is aliased to STREAM_TOKEN_RECEIVED. */
497502 SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received',
498503 STREAM_TOKEN_RECEIVED: 'stream_token_received',
@@ -507,7 +512,7 @@ export const event_types = {
507512 TOOL_CALLS_RENDERED: 'tool_calls_rendered',
508513};
509514
510515export const eventSource = new EventEmitter([event_types.APP_READY]);
511516
512517eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands);
513518
@@ -1027,12 +1032,22 @@ export function setAnimationDuration(ms = null) {
10271032 document.documentElement.style.setProperty('--animation-duration', `${animation_duration}ms`);
10281033}
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+ */
10301039export function setActiveCharacter(entityOrKey) {
10311040 active_character = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
1041+ if (active_character) active_group = null;
10321042}
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+ */
10341048export function setActiveGroup(entityOrKey) {
10351049 active_group = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
1050+ if (active_group) active_character = null;
10361051}
10371052
10381053/**
@@ -3223,6 +3238,7 @@ class StreamingProcessor {
32233238
32243239 // Update reasoning
32253240 await this.reasoningHandler.process(messageId, mesChanged);
3241+ processedText = chat[messageId]['mes'];
32263242
32273243 // Token count update.
32283244 const tokenCountText = this.reasoningHandler.reasoning + processedText;
@@ -3373,7 +3389,7 @@ class StreamingProcessor {
33733389 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));
33743390 }
33753391 // Get the updated reasoning string into the handler
33763392 this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning ?? '');
33773393 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
33783394 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
33793395 }
@@ -6241,9 +6257,35 @@ export async function renameCharacter(name = null, { silent = false, renameChats
62416257 const data = await response.json();
62426258 const newAvatar = data.avatar;
62436259
6244- // Replace tags list
6260+ 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
62456265 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+
62476289 // Reload characters list
62486290 await getCharacters();
62496291
@@ -10947,7 +10989,7 @@ jQuery(async function () {
1094710989 });
1094810990
1094910991 $(document).on('click', '.mes_edit_copy', async function () {
1095010992 const confirmation = await callGenericPopup('t`Create a copy of this message?'`, POPUP_TYPE.CONFIRM);
1095110993 if (!confirmation) {
1095210994 return;
1095310995 }
public/scripts/RossAscends-mods.js+17 -2
@@ -280,17 +280,32 @@ async function RA_autoloadchat() {
280280 // active character is the name, we should look it up in the character list and get the id
281281 if (active_character !== null && active_character !== undefined) {
282282 const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character);
283283 if (active_character_id !== null-1) {
284284 await selectCharacterById(String(active_character_id));
285285
286286 // Do a little tomfoolery to spoof the tag selector
287287 const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`);
288288 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.`);
289293 }
290294 }
291295
292296 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+ }
294309 }
295310
296311 // if the character list hadn't been loaded yet, try again.
public/scripts/chats.js+1 -1
@@ -1487,7 +1487,7 @@ jQuery(function () {
14871487 ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),
14881488 ];
14891489
14901490 download(JSONchatToSave.stringifymap(chatToSave,(m) null,=> 4JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonjsonl`, 'application/json');
14911491 });
14921492
14931493 // Do not change. #attachFile is added by extension.
public/scripts/extensions.js+14 -4
@@ -154,8 +154,18 @@ export const extension_settings = {
154154 refine_mode: false,
155155 },
156156 expressions: {
157+ /** @type {number} see `EXPRESSION_API` */
158+ api: undefined,
157159 /** @type {string[]} */
158160 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,
159169 },
160170 connectionManager: {
161171 selectedProfile: '',
@@ -603,12 +613,12 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
603613 }
604614
605615 let toggleElement = isActive || isDisabled ?
606616 `'<input type="checkbox" title="' + t`Click to toggle` + `" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :
607617 `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;
608618
609619 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>` : '';
610620 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>` : '';
611621 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>` : '';
612622 let modulesInfo = '';
613623
614624 if (isActive && Array.isArray(manifest.optional)) {
@@ -616,7 +626,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
616626 modules.forEach(x => optional.delete(x));
617627 if (optional.size > 0) {
618628 const optionalString = DOMPurify.sanitize([...optional].join(', '));
619629 modulesInfo = `'<div class="extension_modules">' + t`Optional modules:` + ` <span class="optional">${optionalString}</span></div>`;
620630 }
621631 } else if (!isDisabled) { // Neither active nor disabled
622632 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';
1010import { executeSlashCommands } from '../../slash-commands.js';
1111import { accountStorage } from '../../util/AccountStorage.js';
1212import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
13+import { t } from '../../i18n.js';
1314export { MODULE_NAME };
1415
1516const MODULE_NAME = 'assets';
@@ -59,11 +60,11 @@ const KNOWN_TYPES = {
5960 'blip': 'Blip sounds',
6061};
6162
6263async function downloadAssetsList(url) {
6364 updateCurrentAssets().then(async function () {
6465 fetch(url, { cache: 'no-cache' })
6566 .then(response => response.json())
6667 .then(jsonasync =>function(json) {
6768
6869 availableAssets = {};
6970 $('#assets_menu').empty();
@@ -84,10 +85,10 @@ function downloadAssetsList(url) {
8485
8586 $('#assets_type_select').empty();
8687 $('#assets_search').val('');
8788 $('#assets_type_select').append($('<option />', { value: '', text: 't`All'` }));
8889
8990 for (const type of assetTypes) {
9091 const option = $('<option />', { value: type, text: t([KNOWN_TYPES[type] || type]) });
9192 $('#assets_type_select').append(option);
9293 }
9394
@@ -104,11 +105,7 @@ function downloadAssetsList(url) {
104105 assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();
105106
106107 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>`);
112109 }
113110
114111 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) {
184181 const displayName = DOMPurify.sanitize(asset['name'] || asset['id']);
185182 const description = DOMPurify.sanitize(asset['description'] || '');
186183 const url = isValidUrl(asset['url']) ? asset['url'] : '';
187184 const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : 't`Preview in browser'`;
188185 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
189186 const toolTag = assetType === 'extension' && asset['tool'];
190187
@@ -195,9 +192,10 @@ function downloadAssetsList(url) {
195192 <b>${displayName}</b>
196193 <a class="asset_preview" href="${url}" target="_blank" title="${title}">
197194 <i class="fa-solid fa-sm ${previewIcon}"></i>
198195 </a>` +
199196 ${(toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> Tool</span>' : ''}+
200- </span>
197+ t`Tool` + '</span>' : '') +
198+ `</span>
201199 <small class="asset-description">
202200 ${description}
203201 </small>
@@ -435,7 +433,7 @@ jQuery(async () => {
435433 const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
436434 const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
437435
438436 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>`, {
439437 customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
440438 onClose: popup => {
441439 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>
4 \ 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;
3333 <div id="assets_filters" class="flex-container">
3434 <select id="assets_type_select" class="text_pole flex1">
3535 </select>
3636 <input id="assets_search" class="text_pole flex1" data-i18n="[placeholder]Search" placeholder="Search" type="search">
3737 <div id="assets-characters-button" class="menu_button menu_button_icon">
3838 <i class="fa-solid fa-image-portrait"></i>
3939 <span data-i18n="Characters">Characters</span>
public/scripts/extensions/expressions/index.js+864 -783
@@ -1,11 +1,11 @@
11import { Fuse } from '../../../lib.js';
22
33import { callPopupcharacters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
44import { dragElement, isMobile } from '../../RossAscends-mods.js';
55import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
66import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
77import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
88import { hideMutedSprites, selected_group } from '../../group-chats.js';
99import { isJsonSchemaSupported } from '../../textgen-settings.js';
1010import { debounce_timeout } from '../../constants.js';
1111import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -15,16 +15,32 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
1515import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
1616import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
1717import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
18+import { Popup, POPUP_RESULT } from '../../popup.js';
19+import { t } from '../../i18n.js';
1820export { 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+
2038const MODULE_NAME = 'expressions';
2139const UPDATE_INTERVAL = 2000;
2240const STREAMING_UPDATE_INTERVAL = 10000;
23-const TALKINGCHECK_UPDATE_INTERVAL = 500;
2441const DEFAULT_FALLBACK_EXPRESSION = 'joy';
2542const 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}}';
2643const DEFAULT_EXPRESSIONS = [
27- 'talkinghead',
2844 'admiration',
2945 'amusement',
3046 'anger',
@@ -54,6 +70,12 @@ const DEFAULT_EXPRESSIONS = [
5470 'surprise',
5571 'neutral',
5672];
73+
74+const OPTION_NO_FALLBACK = '#none';
75+const OPTION_EMOJI_FALLBACK = '#emoji';
76+const RESET_SPRITE_LABEL = '#reset';
77+
78+
5779/** @enum {number} */
5880const EXPRESSION_API = {
5981 local: 0,
@@ -65,35 +87,29 @@ const EXPRESSION_API = {
6587let expressionsList = null;
6688let lastCharacter = undefined;
6789let lastMessage = null;
68-let lastTalkingState = false;
90+/** @type {{[characterKey: string]: Expression[]}} */
69-let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
7091let spriteCache = {};
7192let inApiCall = false;
7293let lastServerResponseTime = 0;
73-export let lastExpression = {};
74-
75-function 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 one
96+export let lastExpression = {};
81- * @returns {string} expression name
82- */
83-function getFallbackExpression() {
84- return extension_settings.expressions.fallback_expression ?? DEFAULT_FALLBACK_EXPRESSION;
85-}
8697
8798/**
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 button
101+ * @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 AFK
102+ * @returns {ExpressionImage} The placeholder image object
92- * for a long time).
93103 */
94104function toggleTalkingHeadCommandgetPlaceholderImage(_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+ };
97113}
98114
99115function isVisualNovelMode() {
@@ -108,21 +124,21 @@ async function forceUpdateVisualNovelMode() {
108124
109125const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);
110126
111127async function updateVisualNovelMode(namespriteFolderName, expression) {
112128 const containervnContainer = $('#visual-novel-wrapper');
113129
114130 await visualNovelRemoveInactive(containervnContainer);
115131
116132 const setSpritePromises = await visualNovelSetCharacterSprites(containervnContainer, namespriteFolderName, expression);
117133
118134 // calculate layer indices based on recent messages
119135 await visualNovelUpdateLayers(containervnContainer);
120136
121137 await Promise.allSettled(setSpritePromises);
122138
123139 // update again based on new sprites
124140 if (setSpritePromises.length > 0) {
125141 await visualNovelUpdateLayers(containervnContainer);
126142 }
127143}
128144
@@ -153,52 +169,60 @@ async function visualNovelRemoveInactive(container) {
153169 await Promise.allSettled(removeInactiveCharactersPromises);
154170}
155171
156-async 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+ */
180+async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) {
181+ const originalExpression = expression;
157182 const context = getContext();
158183 const group = context.groups.find(x => x.id == context.groupId);
159- const labels = await getExpressionsList();
160184
161- const createCharacterPromises = [];
162185 const setSpritePromises = [];
163186
164187 for (const avatar of group.members) {
165- const isDisabled = group.disabled_members.includes(avatar);
166-
167188 // skip disabled characters
189+ const isDisabled = group.disabled_members.includes(avatar);
168190 if (isDisabled && hideMutedSprites) {
169191 continue;
170192 }
171193
172194 const character = context.characters.find(x => x.avatar == avatar);
173-
174195 if (!character) {
175196 continue;
176197 }
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
180205 // download images if not downloaded yet
181206 if (spriteCache[spriteFolderNamememberSpriteFolderName] === undefined) {
182207 spriteCache[spriteFolderNamememberSpriteFolderName] = await getSpritesList(spriteFolderNamememberSpriteFolderName);
183208 }
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
191212 if (expressionImage!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');
199223 await setImage(img, path);
200224 }
201225 expressionImage.toggleClass('hidden', noSprites!spriteFile);
202226 } else {
203227 const template = $('#expression-holder').clone();
204228 template.attr('id', `expression-${avatar}`);
@@ -206,21 +230,49 @@ async function visualNovelSetCharacterSprites(container, name, expression) {
206230 template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
207231 $('#visual-novel-wrapper').append(template);
208232 dragElement($(template[0]));
209233 template.toggleClass('hidden', noSprites!spriteFile);
210234 awaitimg setImage(= template.find('img'), defaultSpritePath || '');
235+ await setImage(img, spriteFile?.imageSrc || '');
211236 const fadeInPromise = new Promise(resolve => {
212237 template.fadeIn(250, () => resolve());
213238 });
214239 createCharacterPromisessetSpritePromises.push(fadeInPromise);
215- const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
216- setSpritePromises.push(setSpritePromise);
217240 }
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 });
218254 }
219255
220- await Promise.allSettled(createCharacterPromises);
221256 return setSpritePromises;
222257}
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+ */
264+async 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+
224276async function visualNovelUpdateLayers(container) {
225277 const context = getContext();
226278 const group = context.groups.find(x => x.id == context.groupId);
@@ -256,11 +308,17 @@ async function visualNovelUpdateLayers(container) {
256308 const containerWidth = container.width();
257309 const pivotalPoint = containerWidth * 0.5;
258310
259311 let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction);
260312 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());
264322 });
265323
266324 let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
@@ -274,7 +332,7 @@ async function visualNovelUpdateLayers(container) {
274332 currentPosition = 0; // Reset the initial position to 0
275333 }
276334
277335 images.sort(sortFunction).eachforEach((indexcurrent, currentindex) => {
278336 const element = $(current);
279337 const elementID = element.attr('id');
280338
@@ -294,9 +352,15 @@ async function visualNovelUpdateLayers(container) {
294352 element.show();
295353
296354 const promise = new Promise(resolve => {
297- element.animate({ left: currentPosition + 'px' }, 500, () => {
355+ if (power_user.reduced_motion) {
298- resolve();
356+ element.css('left', currentPosition + 'px');
299- });
357+ requestAnimationFrame(() => resolve());
358+ }
359+ else {
360+ element.animate({ left: currentPosition + 'px' }, 500, () => {
361+ resolve();
362+ });
363+ }
300364 });
301365
302366 currentPosition += imagesWidth[index];
@@ -307,23 +371,12 @@ async function visualNovelUpdateLayers(container) {
307371 await Promise.allSettled(setLayerIndicesPromises);
308372}
309373
310-async 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
313-
377+ * @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-
327380async function setImage(img, path) {
328381 // Cohee: If something goes wrong, uncomment this to return to the old behavior
329382 /*
@@ -340,7 +393,7 @@ async function setImage(img, path) {
340393 return new Promise(resolve => {
341394 const prevExpressionSrc = img.attr('src');
342395 const expressionClone = img.clone();
343396 const originalId = img.attrdata('idfilename');
344397
345398 //only swap expressions when necessary
346399 if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
@@ -348,7 +401,7 @@ async function setImage(img, path) {
348401 expressionClone.addClass('expression-clone');
349402 //make invisible and remove id to prevent double ids
350403 //must be made invisible to start because they share the same Z-index
351404 expressionClone.attrdata('idfilename', '').css({ opacity: 0 });
352405 //add new sprite path to clone src
353406 expressionClone.attr('src', path);
354407 //add invisible clone to html
@@ -384,14 +437,18 @@ async function setImage(img, path) {
384437 //remove old expression
385438 img.remove();
386439 //replace ID so it becomes the new 'original' expression for next change
387440 expressionClone.attrdata('idfilename', originalId);
388441 expressionClone.removeClass('expression-animating');
389442
390443 // Reset the expression holder min height and width
391444 expressionHolder.css('min-width', 100);
392445 expressionHolder.css('min-height', 100);
393446
394- resolve();
447+ if (expressionClone.prop('complete')) {
448+ resolve();
449+ } else {
450+ expressionClone.one('load', () => resolve());
451+ }
395452 });
396453
397454 expressionClone.removeClass('expression-clone');
@@ -410,216 +467,9 @@ async function setImage(img, path) {
410467 });
411468}
412469
413-function onExpressionsShowDefaultInput() {
470+async 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- */
433-async 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- */
456-async 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-
572-function 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-
612-async function moduleWorker() {
613471 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-
623473 // non-characters not supported
624474 if (!context.groupId && context.characterId === undefined) {
625475 removeExpression();
@@ -646,7 +496,7 @@ async function moduleWorker() {
646496 }
647497
648498 const currentLastMessage = getLastCharacterMessage();
649499 let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();
650500
651501 // character has no expressions or it is not loaded
652502 if (Object.keys(spriteCache).length === 0) {
@@ -686,6 +536,10 @@ async function moduleWorker() {
686536 offlineMode.css('display', 'none');
687537 }
688538
539+ if (context.groupId && vnMode && newChat) {
540+ await forceUpdateVisualNovelMode();
541+ }
542+
689543 // Don't bother classifying if current char has no sprites and no default expressions are enabled
690544 if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {
691545 return;
@@ -732,11 +586,11 @@ async function moduleWorker() {
732586 const force = !!context.groupId;
733587
734588 // Character won't be angry on you for swiping
735589 if (currentLastMessage.mes == '...' && expressionsList.includes(getFallbackExpression()extension_settings.expressions.fallback_expression)) {
736590 expression = getFallbackExpression()extension_settings.expressions.fallback_expression;
737591 }
738592
739593 await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
740594 }
741595 catch (error) {
742596 console.log(error);
@@ -749,91 +603,6 @@ async function moduleWorker() {
749603 }
750604}
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- */
767-async 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- */
817-async 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-
837606function getSpriteFolderName(characterMessage = null, characterName = null) {
838607 const context = getContext();
839608 let spriteFolderName = characterName ?? context.name2;
@@ -848,33 +617,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
848617 return spriteFolderName;
849618}
850619
851-function 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-
878620function getFolderNameByMessage(message) {
879621 const context = getContext();
880622 let avatarPath = '';
@@ -894,48 +636,55 @@ function getFolderNameByMessage(message) {
894636 return folderName;
895637}
896638
897-async 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+ */
649+export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
650+ lastExpression[spriteFolderName.split('/')[0]] = expression;
651+ if (vnMode === null) {
900652 vnMode = isVisualNovelMode();
901653 }
902654
903655 if (vnMode) {
904656 await updateVisualNovelMode(namespriteFolderName, expression);
905657 } else {
906658 setExpression(namespriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
907659 }
908660}
909661
910662async function setSpriteSetCommandsetSpriteFolderCommand(_, folder) {
911663 if (!folder) {
912664 console.log('Clearing sprite set');
913665 folder = '';
914666 }
915667
916668 if (folder.startsWith('/') || folder.startsWith('\\')) {
917- folder = folder.slice(1);
918-
919669 const currentLastMessage = getLastCharacterMessage();
670+ folder = folder.slice(1);
920671 folder = `${currentLastMessage.name}/${folder}`;
921672 }
922673
923674 $('#expression_override').val(folder.trim());
924675 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);
929678 return '';
930679}
931680
932681async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {
933682 if (!text) {
934683 toastr.warningerror('No text provided');
935684 return '';
936685 }
937686 if (api && !Object.keys(EXPRESSION_API).includes(api)) {
938687 toastr.warningerror('Invalid API provided');
939688 return '';
940689 }
941690
@@ -951,37 +700,69 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
951700 return label;
952701}
953702
954-async function setSpriteSlashCommand(_, spriteId) {
703+/** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
955- if (!spriteId) {
704+async 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`);
957709 return '';
958710 }
959711
960- spriteId = spriteId.trim().toLowerCase();
712+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
713+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
961714
962- // In Talkinghead mode, don't check for the existence of the sprite
715+ let label = searchTerm;
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);
969716
970- // Fuzzy search for sprite
717+ /** @type {string?} */
971- const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
718+ let spriteFile = null;
972- const results = fuse.search(spriteId);
973- const spriteItem = results[0]?.item;
974719
975- if (!spriteItem) {
720+ await validateImages(spriteFolderName);
976- console.log('No sprite found for search term ' + spriteId);
977- return '';
978- }
979721
980- label = spriteItem.label;
722+ // Handle reset as a special term and just reset the sprite via expression call
723+ if (searchTerm === RESET_SPRITE_LABEL) {
724+ await sendExpressionCall(spriteFolderName, label, { force: true });
725+ return lastExpression[spriteFolderName] ?? '';
981726 }
982727
983- const vnMode = isVisualNovelMode();
728+ switch (type) {
984- await sendExpressionCall(spriteFolderName, label, true, vnMode);
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+ }
740+
741+ label = matchedExpression.label;
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`);
754+ return '';
755+ }
756+
757+ label = matchedSprite.expression;
758+ spriteFile = matchedSprite.fileName;
759+ break;
760+ }
761+ default: throw Error('Invalid sprite set type: ' + type);
762+ }
763+
764+ await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
765+
985766 return label;
986767}
987768
@@ -999,6 +780,21 @@ function spriteFolderNameFromCharacter(char) {
999780}
1000781
1001782/**
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+ */
788+function 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+/**
1002798 * Slash command callback for /uploadsprite
1003799 *
1004800 * label= is required
@@ -1011,16 +807,29 @@ function spriteFolderNameFromCharacter(char) {
1011807 * @param {object} args
1012808 * @param {string} args.name Character name or avatar key, passed through findChar
1013809 * @param {string} args.label Expression label
1014810 * @param {string} [args.folder=null] SpriteOptional sprite folder path, processed using backslash rules
811+ * @param {string?} [args.spriteName=null] Optional sprite name
1015812 * @param {string} imageUrl Image URI to fetch and upload
1016813 * @returns {Promise<voidstring>} the sprite name
1017814 */
1018815async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
1019816 if (!imageUrl) throw new Error('Image URL is required');
1020817 if (!label || typeof label !== 'string') throw new Error('Expression label is required');{
818+ toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
819+ return '';
820+ }
1021821
1022822 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
1025834 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
1026835 const char = findChar({ name });
@@ -1040,7 +849,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1040849 const formData = new FormData();
1041850 formData.append('name', folder); // this is the folder or character name
1042851 formData.append('label', label); // this is the expression label
1043852 formData.append('avatar', file); // this is the image file
853+ formData.append('spriteName', spriteName); // this is a redundant comment
1044854
1045855 await handleFileUpload('/api/sprites/upload', formData);
1046856 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
@@ -1048,6 +858,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1048858 console.error(`[${MODULE_NAME}] Error uploading file:`, error);
1049859 throw error;
1050860 }
861+
862+ return spriteName;
1051863}
1052864
1053865/**
@@ -1159,7 +971,7 @@ function getJsonSchema(emotions) {
1159971function onTextGenSettingsReady(args) {
1160972 // Only call if inside an API call
1161973 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {
1162- const emotions = DEFAULT_EXPRESSIONS.filter((e) => e != 'talkinghead');
974+ const emotions = DEFAULT_EXPRESSIONS;
1163975 Object.assign(args, {
1164976 top_k: 1,
1165977 stop: [],
@@ -1177,16 +989,16 @@ function onTextGenSettingsReady(args) {
1177989 * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.
1178990 * @param {object} [options={}] - Optional arguments.
1179991 * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.
1180992 * @returns {Promise<string?>} - The label of the expression.
1181993 */
1182994export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) {
1183995 // Return if text is undefined, saving a costly fetch request
1184996 if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {
1185997 return getFallbackExpression()extension_settings.expressions.fallback_expression;
1186998 }
1187999
11881000 if (extension_settings.expressions.translate && typeof window['globalThis.translate'] === 'function') {
11891001 text = await window['globalThis.translate'](text, 'en');
11901002 }
11911003
11921004 text = sampleClassifyText(text);
@@ -1212,7 +1024,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12121024 await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
12131025 } catch (error) {
12141026 console.warn('No LLM connection. Using fallback expression', error);
12151027 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12161028 }
12171029
12181030 const expressionsList = await getExpressionsList();
@@ -1225,7 +1037,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12251037 case EXPRESSION_API.webllm: {
12261038 if (!isWebLlmSupported()) {
12271039 console.warn('WebLLM is not supported. Using fallback expression');
12281040 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12291041 }
12301042
12311043 const expressionsList = await getExpressionsList();
@@ -1258,9 +1070,9 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12581070 } break;
12591071 }
12601072 } catch (error) {
12611073 toastr.infoerror('Could not classify expression. Check the console or your backend for more information.');
12621074 console.error(error);
12631075 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12641076 }
12651077}
12661078
@@ -1288,75 +1100,155 @@ function removeExpression() {
12881100 $('#no_chat_expressions').show();
12891101}
12901102
1291-async 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+ */
1108+async function validateImages(spriteFolderName, forceRedrawCached = false) {
1109+ if (!spriteFolderName) {
12931110 return;
12941111 }
12951112
12961113 const labels = await getExpressionsList();
12971114
12981115 if (spriteCache[characterspriteFolderName]) {
12991116 if (forceRedrawCached && $('#image_list').data('name') !== characterspriteFolderName) {
13001117 console.debug('force redrawing character sprites list');
13011118 await drawSpritesList(characterspriteFolderName, labels, spriteCache[characterspriteFolderName]);
13021119 }
13031120
13041121 return;
13051122 }
13061123
13071124 const sprites = await getSpritesList(characterspriteFolderName);
13081125 let validExpressions = await drawSpritesList(characterspriteFolderName, labels, sprites);
13091126 spriteCache[characterspriteFolderName] = 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+ */
1134+function 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+ };
13101145}
13111146
1312-async 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+ */
1154+async function drawSpritesList(spriteFolderName, labels, sprites) {
1155+ /** @type {Expression[]} */
13131156 let validExpressions = [];
1157+
13141158 $('#no_chat_expressions').hide();
13151159 $('#open_chat_expressions').show();
13161160 $('#image_list').empty();
13171161 $('#image_list').data('name', characterspriteFolderName);
13181162 $('#image_list_header_name').text(characterspriteFolderName);
13191163
13201164 if (!Array.isArray(labels)) {
13211165 return [];
13221166 }
13231167
13241168 for (const itemexpression 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
1171+ .filter(s => s.label === expression)
1172+ .map(s => s.files)
1173+ .flat();
13271174
13281175 if (spriteimages.length === 0) {
1329- validExpressions.push(sprite);
1176+ const listItem = await getListItem(expression, {
1330- const listItem = await getListItem(item, sprite.path, 'success', isCustom);
1177+ isCustom,
1331- $('#image_list').append(listItem);
1178+ images: [getPlaceholderImage(expression, isCustom)],
1332- }
1179+ });
1333- else {
1334- const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom);
13351180 $('#image_list').append(listItem);
1181+ continue;
13361182 }
1183+
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+ });
1191+ $('#image_list').append(listItem);
13371192 }
13381193 return validExpressions;
13391194}
13401195
13411196/**
13421197 * Renders a list item template for the expressions list.
13431198 * @param {string} itemexpression Expression name
13441199 * @param {stringobject} imageSrc Pathargs toArguments imageobject
1345- * @param {'success' | 'failure'} textClass 'success' or 'failure'
1200+ * @param {ExpressionImage[]} [args.images] Array of image objects
13461201 * @param {boolean} [args.isCustom=false] If expression is added by user
13471202 * @returns {Promise<string>} Rendered list item template
13481203 */
13491204async function getListItem(itemexpression, imageSrc,{ textClassimages, isCustom = false } = {}) {
13501205 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { itemexpression, imageSrcimages, textClass,isCustom: isCustom ?? false });
13511206}
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+
13531216async function getSpritesList(name) {
13541217 console.debug('getting sprites list');
13551218
13561219 try {
13571220 const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
1221+ /** @type {{ label: string, path: string }[]} */
13581222 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;
13601252 }
13611253 catch (err) {
13621254 console.log(err);
@@ -1395,17 +1287,31 @@ async function renderFallbackExpressionPicker() {
13951287 const defaultPicker = $('#expression_fallback');
13961288 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
14001294 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) {
14011300 const option = document.createElement('option');
14021301 option.value = expressionvalue;
14031302 option.text = expressionlabel;
14041303 option.selected = expression == fallbackExpressionisSelected;
14051304 defaultPicker.append(option);
14061305 }
14071306}
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+
14091315function getCachedExpressions() {
14101316 if (!Array.isArray(expressionsList)) {
14111317 return [];
@@ -1463,7 +1369,7 @@ export async function getExpressionsList() {
14631369 }
14641370
14651371 // If there was no specific list, or an error, just return the default expressions
14661372 expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice();
14671373 return expressionsList;
14681374 }
14691375
@@ -1471,161 +1377,221 @@ export async function getExpressionsList() {
14711377 return [...result, ...extension_settings.expressions.custom].filter(onlyUnique);
14721378}
14731379
1474-async 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.
1478- const img = $('img.expression');
1384+ * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same.
1479- const prevExpressionSrc = img.attr('src');
1385+ * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one.
1480- const expressionClone = img.clone();
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+ */
1394+function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) {
1395+ if (!spriteCache[spriteFolderName]) return null;
1396+ if (expression === RESET_SPRITE_LABEL) return null;
14811397
1482- const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
1398+ // Search for sprites of that expression - or fallback expression sprites if enabled
1483- console.debug('checking for expression images to show..');
1399+ let sprite = spriteCache[spriteFolderName].find(x => x.label === expression);
1484- if (sprite) {
1400+ if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) {
1485- console.debug('setting expression from character images folder');
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;
14861405
1487- if (force && isVisualNovelMode()) {
1406+ let spriteFile = sprite.files[0];
1488- const context = getContext();
1489- const group = context.groups.find(x => x.id === context.groupId);
14901407
1491- for (const member of group.members) {
1408+ // If a specific sprite file should be set, we are looking it up here
1492- const groupMember = context.characters.find(x => x.avatar === member);
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+ }
14931422
1494- if (!groupMember) {
1423+ return spriteFile;
1495- continue;
1496- }
14971424
1498- if (groupMember.name == character) {
1425+}
1499- await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);
1426+
1500- return;
1427+/**
1501- }
1428+ * Set the expression of a character.
1502- }
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+ */
1436+async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) {
1437+ await validateImages(spriteFolderName);
1438+ const img = $('img.expression');
1439+ const prevExpressionSrc = img.attr('src');
1440+ const expressionClone = img.clone();
1441+
1442+ const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile });
1443+ if (spriteFile) {
1444+ if (force && isVisualNovelMode()) {
1445+ const context = getContext();
1446+ const group = context.groups.find(x => x.id === context.groupId);
1447+
1448+ // If it's a folder, make sure we find the group member based on the actual name
1449+ const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName;
1450+
1451+ const groupMember = group.members
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);
1456+ return;
15031457 }
1504- //only swap expressions when necessary
1458+ }
1505- if (prevExpressionSrc !== sprite.path
1506- && !img.hasClass('expression-animating')) {
1507- //clone expression
1508- expressionClone.addClass('expression-clone');
1509- //make invisible and remove id to prevent double ids
1510- //must be made invisible to start because they share the same Z-index
1511- expressionClone.attr('id', '').css({ opacity: 0 });
1512- //add new sprite path to clone src
1513- expressionClone.attr('src', sprite.path);
1514- //add invisible clone to html
1515- expressionClone.appendTo($('#expression-holder'));
1516-
1517- const duration = 200;
1518-
1519- //add animation flags to both images
1520- //to prevent multiple expression changes happening simultaneously
1521- img.addClass('expression-animating');
1522-
1523- // Set the parent container's min width and height before running the transition
1524- const imgWidth = img.width();
1525- const imgHeight = img.height();
1526- const expressionHolder = img.parent();
1527- expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
1528- expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
1529-
1530- //position absolute prevent the original from jumping around during transition
1531- img.css('position', 'absolute').width(imgWidth).height(imgHeight);
1532- expressionClone.addClass('expression-animating');
1533- //fade the clone in
1534- expressionClone.css({
1535- opacity: 0,
1536- }).animate({
1537- opacity: 1,
1538- }, duration)
1539- //when finshed fading in clone, fade out the original
1540- .promise().done(function () {
1541- img.animate({
1542- opacity: 0,
1543- }, duration);
1544- //remove old expression
1545- img.remove();
1546- //replace ID so it becomes the new 'original' expression for next change
1547- expressionClone.attr('id', 'expression-image');
1548- expressionClone.removeClass('expression-animating');
1549-
1550- // Reset the expression holder min height and width
1551- expressionHolder.css('min-width', 100);
1552- expressionHolder.css('min-height', 100);
1553- });
15541459
1460+ //only swap expressions when necessary
1461+ if (prevExpressionSrc !== spriteFile.imageSrc
1462+ && !img.hasClass('expression-animating')) {
1463+ //clone expression
1464+ expressionClone.addClass('expression-clone');
1465+ //make invisible and remove id to prevent double ids
1466+ //must be made invisible to start because they share the same Z-index
1467+ expressionClone.attr('id', '').css({ opacity: 0 });
1468+ //add new sprite path to clone src
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);
1475+ //add invisible clone to html
1476+ expressionClone.appendTo($('#expression-holder'));
15551477
1556- expressionClone.removeClass('expression-clone');
1478+ const duration = 200;
15571479
1558- expressionClone.removeClass('default');
1480+ //add animation flags to both images
1559- expressionClone.off('error');
1481+ //to prevent multiple expression changes happening simultaneously
1560- expressionClone.on('error', function () {
1482+ img.addClass('expression-animating');
1561- console.debug('Expression image error', sprite.path);
1562- $(this).attr('src', '');
1563- $(this).off('error');
1564- if (force && extension_settings.expressions.showDefault) {
1565- setDefault();
1566- }
1567- });
1568- }
1569- }
1570- else {
1571- if (extension_settings.expressions.showDefault) {
1572- setDefault();
1573- }
1574- }
15751483
1576- function setDefault() {
1484+ // Set the parent container's min width and height before running the transition
1577- console.debug('setting default');
1485+ const imgWidth = img.width();
15781486 const defImgUrlimgHeight = `/img/default-expressions/${expression}.png`height();
1579- //console.log(defImgUrl);
1487+ const expressionHolder = img.parent();
1580- img.attr('src', defImgUrl);
1488+ expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
1581- img.addClass('default');
1489+ expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
1582- }
1583- document.getElementById('expression-holder').style.display = '';
15841490
1585- } else {
1491+ //position absolute prevent the original from jumping around during transition
1586- // Set the Talkinghead emotion to the specified expression
1492+ img.css('position', 'absolute').width(imgWidth).height(imgHeight);
1587- // TODO: For now, Talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.
1493+ expressionClone.addClass('expression-animating');
1588- try {
1494+ //fade the clone in
1589- let result = await isTalkingHeadAvailable();
1495+ expressionClone.css({
1590- if (result) {
1496+ opacity: 0,
1591- const url = new URL(getApiUrl());
1497+ }).animate({
1592- url.pathname = '/api/talkinghead/set_emotion';
1498+ opacity: 1,
1593- await doExtrasFetch(url, {
1499+ }, duration)
1594- method: 'POST',
1500+ //when finshed fading in clone, fade out the original
1595- headers: {
1501+ .promise().done(function () {
1596- 'Content-Type': 'application/json',
1502+ img.animate({
1597- },
1503+ opacity: 0,
1598- body: JSON.stringify({ emotion_name: expression }),
1504+ }, duration);
1505+ //remove old expression
1506+ img.remove();
1507+ //replace ID so it becomes the new 'original' expression for next change
1508+ expressionClone.attr('id', 'expression-image');
1509+ expressionClone.removeClass('expression-animating');
1510+
1511+ // Reset the expression holder min height and width
1512+ expressionHolder.css('min-width', 100);
1513+ expressionHolder.css('min-height', 100);
15991514 });
1600- }
1601- }
1602- catch (error) {
1603- // `set_emotion` is not present in old versions, so let it 404.
1604- }
16051515
1606- try {
1516+ expressionClone.removeClass('expression-clone');
1607- // Find the <img> element with id="expression-image" and class="expression"
1517+
1608- const imgElement = document.querySelector('img#expression-image.expression');
1518+ expressionClone.removeClass('default');
1609- //console.log("searching");
1519+ expressionClone.off('error');
1610- if (imgElement && imgElement instanceof HTMLImageElement) {
1520+ expressionClone.on('error', function (error) {
1611- //console.log("setting value");
1521+ console.debug('Expression image error', spriteFile.imageSrc, error);
1612- imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
1522+ $(this).attr('src', '');
1613- }
1523+ $(this).off('error');
1524+ if (force && extension_settings.expressions.showDefault) {
1525+ setDefaultEmojiForImage(img, expression);
1526+ }
1527+ });
16141528 }
1615- catch (error) {
1529+
1616- //console.log("The fetch failed!");
1530+ console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
1531+ }
1532+ else {
1533+ img.attr('data-sprite-folder-name', spriteFolderName);
1534+
1535+ img.off('error');
1536+
1537+ if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) {
1538+ setDefaultEmojiForImage(img, expression);
1539+ } else {
1540+ setNoneForImage(img, expression);
16171541 }
1542+ console.debug('Expression unset - No sprite found', { expression: expression });
16181543 }
1544+
1545+ document.getElementById('expression-holder').style.display = '';
1546+}
1547+
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+ */
1553+function 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;
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');
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+ */
1572+function 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');
16191578}
16201579
16211580function 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);
16241590}
16251591
16261592async function onClickExpressionAddCustom() {
16271593 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');
16281594 let expressionName = await callPopupPopup.show.input(templatenull, 'input'template);
16291595
16301596 if (!expressionName) {
16311597 console.debug('No custom expression name provided');
@@ -1636,19 +1602,15 @@ async function onClickExpressionAddCustom() {
16361602
16371603 // a-z, 0-9, dashes and underscores only
16381604 if (!/^[a-z0-9-_]+$/.test(expressionName)) {
16391605 toastr.infowarning('Invalid custom expression name provided', 'Add Custom Expression');
16401606 return;
16411607 }
1642-
1608+ if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) {
1643- // Check if expression name already exists in default expressions
1609+ toastr.warning('Expression name already exists', 'Add Custom Expression');
1644- if (DEFAULT_EXPRESSIONS.includes(expressionName)) {
1645- toastr.info('Expression name already exists');
16461610 return;
16471611 }
1648-
1649- // Check if expression name already exists in custom expressions
16501612 if (extension_settings.expressions.custom.includes(expressionName)) {
16511613 toastr.infowarning('Custom expression already exists', 'Add Custom Expression');
16521614 return;
16531615 }
16541616
@@ -1665,14 +1627,15 @@ async function onClickExpressionAddCustom() {
16651627
16661628async function onClickExpressionRemoveCustom() {
16671629 const selectedExpression = String($('#expression_custom').val());
1630+ const noCustomExpressions = extension_settings.expressions.custom.length === 0;
16681631
16691632 if (!selectedExpression || noCustomExpressions) {
16701633 console.debug('No custom expression selected');
16711634 return;
16721635 }
16731636
16741637 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });
16751638 const confirmation = await callPopupPopup.show.confirm(templatenull, 'confirm'template);
16761639
16771640 if (!confirmation) {
16781641 console.debug('Custom expression removal cancelled');
@@ -1682,8 +1645,8 @@ async function onClickExpressionRemoveCustom() {
16821645 // Remove custom expression from settings
16831646 const index = extension_settings.expressions.custom.indexOf(selectedExpression);
16841647 extension_settings.expressions.custom.splice(index, 1);
16851648 if (selectedExpression == getFallbackExpression()extension_settings.expressions.fallback_expression) {
16861649 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');
16871650 extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;
16881651 }
16891652 await renderAdditionalExpressionSettings();
@@ -1707,12 +1670,35 @@ function onExpressionApiChanged() {
17071670 }
17081671}
17091672
17101673async 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 });
17151699 }
1700+
1701+ saveSettingsDebounced();
17161702}
17171703
17181704async function handleFileUpload(url, formData) {
@@ -1739,34 +1725,111 @@ async function handleFileUpload(url, formData) {
17391725 }
17401726}
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+ */
1733+function withoutExtension(fileName) {
1734+ return fileName.replace(/\.[^/.]+$/, '');
1735+}
1736+
1737+function validateExpressionSpriteName(expression, spriteName) {
1738+ const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1739+ const validFileName = filenameValidationRegex.test(spriteName);
1740+ return validFileName;
1741+}
1742+
17421743async function onClickExpressionUpload(event) {
17431744 // Prevents the expression from being set
17441745 event.stopPropagation();
17451746
17461747 const idexpressionListItem = $(this).closest('.expression_list_item').attr('id');
1748+
1749+ const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null;
1750+ const expression = expressionListItem.data('expression');
17471751 const name = $('#image_list').data('name');
17481752
17491753 const handleExpressionUploadChange = async (e) => {
17501754 const file = e.target.files[0];
17511755
17521756 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();
17531820 return;
17541821 }
17551822
17561823 const formData = new FormData();
17571824 formData.append('name', name);
17581825 formData.append('label', idexpression);
17591826 formData.append('avatar', file);
1827+ formData.append('spriteName', spriteName);
17601828
17611829 await handleFileUpload('/api/sprites/upload', formData);
17621830
17631831 // Reset the input
17641832 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- }
17701833 };
17711834
17721835 $('#expression_upload')
@@ -1822,8 +1885,9 @@ async function onClickExpressionOverrideButton() {
18221885 inApiCall = true;
18231886 $('#visual-novel-wrapper').empty();
18241887 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
1888+ const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
18251889 const expression = await getExpressionLabel(currentLastMessage.mes);
18261890 await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, { force: true });
18271891 forceUpdateVisualNovelMode();
18281892 } catch (error) {
18291893 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
@@ -1849,7 +1913,7 @@ async function onClickExpressionOverrideRemoveAllButton() {
18491913 const currentLastMessage = getLastCharacterMessage();
18501914 await validateImages(currentLastMessage.name, true);
18511915 const expression = await getExpressionLabel(currentLastMessage.mes);
18521916 await sendExpressionCall(currentLastMessage.name, expression, { force: true });
18531917 forceUpdateVisualNovelMode();
18541918
18551919 console.debug(extension_settings.expressionOverrides);
@@ -1872,16 +1936,13 @@ async function onClickExpressionUploadPackButton() {
18721936 formData.append('name', name);
18731937 formData.append('avatar', file);
18741938
1939+ const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 });
18751940 const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
1941+ toastr.clear(uploadToast);
18761942 toastr.success(`Uploaded ${count} image(s) for ${name}`);
18771943
18781944 // Reset the input
18791945 e.target.form.reset();
1880-
1881- // In Talkinghead mode, refresh the live char.
1882- if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1883- await loadTalkingHead();
1884- }
18851946 };
18861947
18871948 $('#expression_upload_pack')
@@ -1894,20 +1955,28 @@ async function onClickExpressionDelete(event) {
18941955 // Prevents the expression from being set
18951956 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>');
18991968 if (!confirmation) {
19001969 return;
19011970 }
19021971
19031972 const idfileName = $(this).closestwithoutExtension('.expression_list_item')expressionListItem.attr('iddata-filename'));
19041973 const name = $('#image_list').data('name');
19051974
19061975 try {
19071976 await fetch('/api/sprites/delete', {
19081977 method: 'POST',
19091978 headers: getRequestHeaders(),
19101979 body: JSON.stringify({ name, label: idexpression, spriteName: fileName }),
19111980 });
19121981 } catch (error) {
19131982 toastr.error('Failed to delete image. Try again later.');
@@ -1984,6 +2053,16 @@ function migrateSettings() {
19842053 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
19852054 saveSettingsDebounced();
19862055 }
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+ }
19872066}
19882067
19892068(async function () {
@@ -2010,13 +2089,19 @@ function migrateSettings() {
20102089 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
20112090 $('#expressions_container').append(template);
20122091 $('#expression_override_button').on('click', onClickExpressionOverrideButton);
2013- $('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
20142092 $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
2015- $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
20162093 $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {
20172094 extension_settings.expressions.translate = !!$(this).prop('checked');
20182095 saveSettingsDebounced();
20192096 });
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+ });
20202105 $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
20212106 $(document).on('dragstart', '.expression', (e) => {
20222107 e.preventDefault();
@@ -2025,21 +2110,15 @@ function migrateSettings() {
20252110 $(document).on('click', '.expression_list_item', onClickExpressionImage);
20262111 $(document).on('click', '.expression_list_upload', onClickExpressionUpload);
20272112 $(document).on('click', '.expression_list_delete', onClickExpressionDelete);
20282113 $(window).on('resize', () => updateVisualNovelModeDebounced());
20292114 $('#open_chat_expressions').hide();
20302115
2031- $('#image_type_toggle').on('click', function () {
2032- if (this instanceof HTMLInputElement) {
2033- setTalkingHeadState(this.checked);
2034- }
2035- });
2036-
20372116 await renderAdditionalExpressionSettings();
20382117 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);
20392118 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
20402119 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');
20412120 $('#expression_llm_prompt').on('input', function () {
20422121 extension_settings.expressions.llmPrompt = String($(this).val());
20432122 saveSettingsDebounced();
20442123 });
20452124 $('#expression_llm_prompt_restore').on('click', function () {
@@ -2054,34 +2133,6 @@ function migrateSettings() {
20542133 $('#expression_api').on('change', onExpressionApiChanged);
20552134 }
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-
20852136 addExpressionImage();
20862137 addVisualNovelMode();
20872138 migrateSettings();
@@ -2090,11 +2141,6 @@ function migrateSettings() {
20902141 const updateFunction = wrapper.update.bind(wrapper);
20912142 setInterval(updateFunction, UPDATE_INTERVAL);
20922143 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();
20982144 dragElement($('#expression-holder'));
20992145 eventSource.on(event_types.CHAT_CHANGED, () => {
21002146 // character changed
@@ -2108,110 +2154,137 @@ function migrateSettings() {
21082154 imgElement.src = '';
21092155 }
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-
21172157 setExpressionOverrideHtml();
21182158
21192159 if (isVisualNovelMode()) {
21202160 $('#visual-novel-wrapper').empty();
21212161 }
21222162
21232163 updateFunction({ newChat: true });
21242164 });
21252165 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
21262166 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
21332168 const localEnumProviders = {
21342169 expressions: () => getCachedExpressions().map(expression => {
21352170 const isCustomcurrentLastMessage = extension_settings.expressions.customselected_group ?.includes getLastCharacterMessage(expression) : null;
2136- return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D');
2171+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2137- }),
2172+ const expressions = getCachedExpressions();
2173+ return expressions.map(expression => {
2174+ const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
2175+ const isCustom = extension_settings.expressions.custom?.includes(expression);
2176+ const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
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+ },
21382195 };
21392196
21402197 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21412198 name: 'spriteexpression-set',
21422199 aliases: ['sprite', 'emote'],
21432200 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+ ],
21442211 unnamedArgumentList: [
21452212 SlashCommandArgument.fromProps({
21462213 description: 'spriteIdexpression label to set',
21472214 typeList: [ARGUMENT_TYPE.STRING],
21482215 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+ },
21502225 }),
21512226 ],
21522227 helpString: 'Force sets the spriteexpression for the current character.',
21532228 returns: 'theThe currently set spriteexpression label after setting it.',
21542229 }));
21552230 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21562231 name: 'spriteoverrideexpression-folder-override',
21572232 aliases: ['spriteoverride', 'costume'],
21582233 callback: setSpriteSetCommandsetSpriteFolderCommand,
21592234 unnamedArgumentList: [
21602235 new SlashCommandArgument(
21612236 'optional folder', [ARGUMENT_TYPE.STRING], false,
21622237 ),
21632238 ],
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+ `,
21652248 }));
21662249 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21672250 name: 'lastspriteexpression-last',
2168- callback: (_, name) => {
2251+ aliases: ['lastsprite'],
2252+ /** @type {(args: object, name: string) => Promise<string>} */
2253+ callback: async (_, name) => {
21692254 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+
21702263 const char = findChar({ name: name });
2264+ if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
2265+
21712266 const sprite = lastExpression[char?.name ?? name] ?? '';
21722267 return sprite;
21732268 },
21742269 returns: 'the last set sprite / expression for the named character.',
21752270 unnamedArgumentList: [
21762271 SlashCommandArgument.fromProps({
21772272 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)',
21782273 typeList: [ARGUMENT_TYPE.STRING],
2179- isRequired: true,
21802274 enumProvider: commonEnumProviders.characters('character'),
21812275 }),
21822276 ],
21832277 helpString: 'Returns the last set sprite / 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.',
21912278 }));
21922279 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21932280 name: 'classifyexpression-expressionslist',
21942281 aliases: ['expressions'],
2282+ /** @type {(args: {return: string}) => Promise<string>} */
21952283 callback: async (args) => {
2196- /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2284+ let returnType =
2197- // @ts-ignore
2285+ /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2198- let returnType = args.return;
2286+ (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
22152288 const list = await getExpressionsList();
22162289
22172290 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
@@ -2225,22 +2298,13 @@ function migrateSettings() {
22252298 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
22262299 forceEnum: true,
22272300 }),
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- }),
22382301 ],
22392302 returns: 'The comma-separated list of available expressions, including custom expressions.',
22402303 helpString: 'Returns a list of available expressions, including custom expressions.',
22412304 }));
22422305 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
22432306 name: 'expression-classify',
2307+ aliases: ['classify'],
22442308 callback: classifyCallback,
22452309 namedArgumentList: [
22462310 SlashCommandNamedArgument.fromProps({
@@ -2279,11 +2343,13 @@ function migrateSettings() {
22792343 `,
22802344 }));
22812345 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
22822346 name: 'uploadspriteexpression-upload',
2347+ aliases: ['uploadsprite'],
2348+ /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
22832349 callback: async (args, url) => {
22842350 return await uploadSpriteCommand(args, url);
2285- return '';
22862351 },
2352+ returns: 'the resulting sprite name',
22872353 unnamedArgumentList: [
22882354 SlashCommandArgument.fromProps({
22892355 description: 'URL of the image to upload',
@@ -2297,7 +2363,6 @@ function migrateSettings() {
22972363 description: 'Character name or avatar key (default is current character)',
22982364 typeList: [ARGUMENT_TYPE.STRING],
22992365 isRequired: false,
2300- acceptsMultiple: false,
23012366 }),
23022367 SlashCommandNamedArgument.fromProps({
23032368 name: 'label',
@@ -2305,16 +2370,32 @@ function migrateSettings() {
23052370 typeList: [ARGUMENT_TYPE.STRING],
23062371 enumProvider: localEnumProviders.expressions,
23072372 isRequired: true,
2308- acceptsMultiple: false,
23092373 }),
23102374 SlashCommandNamedArgument.fromProps({
23112375 name: 'folder',
23122376 description: 'Override folder to upload into',
23132377 typeList: [ARGUMENT_TYPE.STRING],
23142378 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,
23162385 }),
23172386 ],
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+ `,
23192400 }));
23202401})();
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}}">
23 <div class="expression_list_buttons">
34 <div class="menu_button expression_list_upload" title="Upload image">
45 <i class="fa-solid fa-upload"></i>
@@ -7,11 +8,14 @@
78 <i class="fa-solid fa-trash"></i>
89 </div>
910 </div>
1011 <div class="expression_list_title {{textClass}}">
1112 <span>{{item../expression}}</span>
1213 {{#if ../isCustom}}
1314 <small class="expression_list_custom">(custom)</small>
1415 {{/if}}
1516 </div>
1617 <imgdiv class="expression_list_imageexpression_list_image_container" srctitle="{{imageSrcthis.title}}" />
18+ <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
19+ </div>
1720</div>
21+{{/each}}
public/scripts/extensions/expressions/settings.html+21 -9
@@ -6,17 +6,17 @@
66 </div>
77
88 <div class="inline-drawer-content">
99 <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.">
1010 <input id="expression_translate" type="checkbox">
1111 <span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
1212 </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.">
1414 <input id="expressions_show_defaultexpressions_allow_multiple" type="checkbox">
1515 <span data-i18n="Show default imagesAllow (emojis)multiple ifsprites spriteper missingexpression">Show default imagesAllow (emojis)multiple ifsprites spriteper missingexpression</span>
1616 </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.">
1818 <input id="image_type_toggleexpressions_reroll_if_same" type="checkbox">
1919 <span data-i18n="ImageRe-roll Typeif -same talkingheadexpression (extras)is used again">ImageRe-roll Typeif -same talkingheadsprite (extras)is used again</span>
2020 </label>
2121 <div class="expression_api_block m-b-1 m-t-1">
2222 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>
@@ -75,8 +75,20 @@
7575 <span data-i18n="Remove all image overrides">Remove all image overrides</span>
7676 </div>
7777 </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>
8092 <h3 id="image_list_header">
8193 <strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
8294 </h3>
public/scripts/extensions/expressions/style.css+31 -2
@@ -111,6 +111,10 @@ img.expression.default {
111111 justify-content: center;
112112}
113113
114+.expression_list_image_container {
115+ overflow: hidden;
116+}
117+
114118.expression_list_title {
115119 position: absolute;
116120 bottom: 0;
@@ -126,6 +130,9 @@ img.expression.default {
126130 flex-direction: column;
127131 line-height: 1;
128132}
133+.expression_list_custom {
134+ font-size: 0.66rem;
135+}
129136
130137.expression_list_buttons {
131138 position: absolute;
@@ -162,11 +169,24 @@ img.expression.default {
162169 row-gap: 1rem;
163170}
164171
165172#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
166173 color: green;
167174}
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 {
170190 color: red;
171191}
172192
@@ -189,3 +209,12 @@ img.expression.default {
189209 flex-direction: row;
190210}
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
66import { resetScrollHeight, debounce } from '../../utils.js';
77import { debounce_timeout } from '../../constants.js';
88import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
9+import { renderExtensionTemplateAsync } from '../../extensions.js';
10+import { t } from '../../i18n.js';
911
1012function rgb2hex(rgb) {
1113 rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
@@ -22,23 +24,7 @@ $('button').click(function () {
2224
2325async function doTokenCounter() {
2426 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
4329 const dialog = $(html);
4430 const countDebounced = debounce(async () => {
@@ -131,9 +117,9 @@ async function doCount() {
131117jQuery(() => {
132118 const buttonHtml = `
133119 <div id="token_counter" class="list-group-item flex-container flexGap5">
134120 <div class="fa-solid fa-1 extensionsMenuExtensionButton" /></div>` +
135121 t`Token Counter` +
136122 '</div>`';
137123 $('#token_counter_wand_container').append(buttonHtml);
138124 $('#token_counter').on('click', doTokenCounter);
139125 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>
16 \ No newline at end of file
public/scripts/extensions/translate/index.js+1 -1
@@ -605,7 +605,7 @@ const handleOutgoingMessage = createEventHandler(translateOutgoingMessage, () =>
605605const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));
606606const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);
607607
608608window['globalThis.translate'] = translate;
609609
610610jQuery(async () => {
611611 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
2727import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
2828import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
2929import { GoogleTranslateTtsProvider } from './google-translate.js';
30-export { talkingAnimation };
3130
3231const UPDATE_INTERVAL = 1000;
3332const wrapper = new ModuleWorkerWrapper(moduleWorker);
3433
3534let voiceMapEntries = [];
3635let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
37-let talkingHeadState = false;
3836let lastChatId = null;
3937let lastMessage = null;
4038let lastMessageHash = null;
@@ -166,27 +164,6 @@ async function moduleWorker() {
166164 updateUiAudioPlayState();
167165}
168166
169-function 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-
190167function resetTtsPlayback() {
191168 // Stop system TTS utterance
192169 cancelTtsPlay();
@@ -378,7 +355,6 @@ function onAudioControlClicked() {
378355 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
379356 if (!audioElement.paused || isTtsProcessing()) {
380357 resetTtsPlayback();
381- talkingAnimation(false);
382358 } else {
383359 // Default play behavior if not processing or playing is to play the last message.
384360 processAndQueueTtsMessage(context.chat[context.chat.length - 1]);
@@ -405,7 +381,6 @@ function addAudioControl() {
405381function completeCurrentAudioJob() {
406382 audioQueueProcessorReady = true;
407383 currentAudioJob = null;
408- talkingAnimation(false); //stop lip animation
409384 // updateUiPlayState();
410385 wrapper.update();
411386}
@@ -436,7 +411,6 @@ async function processAudioJobQueue() {
436411 audioQueueProcessorReady = false;
437412 currentAudioJob = audioJobQueue.shift();
438413 playAudioData(currentAudioJob);
439- talkingAnimation(true);
440414 } catch (error) {
441415 toastr.error(error.toString());
442416 console.error(error);
public/scripts/extensions/tts/openai-compatible.js+3 -3
@@ -25,7 +25,7 @@ class OpenAICompatibleTtsProvider {
2525 <label for="openai_compatible_tts_endpoint">Provider Endpoint:</label>
2626 <div class="flex-container alignItemsCenter">
2727 <div class="flex1">
2828 <input id="openai_compatible_tts_endpoint" type="text" class="text_pole" maxlength="250500" value="${this.defaultSettings.provider_endpoint}"/>
2929 </div>
3030 <div id="openai_compatible_tts_key" class="menu_button menu_button_icon">
3131 <i class="fa-solid fa-key"></i>
@@ -33,9 +33,9 @@ class OpenAICompatibleTtsProvider {
3333 </div>
3434 </div>
3535 <label for="openai_compatible_model">Model:</label>
3636 <input id="openai_compatible_model" type="text" class="text_pole" maxlength="250500" value="${this.defaultSettings.model}"/>
3737 <label for="openai_compatible_tts_voices">Available Voices (comma separated):</label>
3838 <input id="openai_compatible_tts_voices" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.available_voices.join()}"/>
3939 <label for="openai_compatible_tts_speed">Speed: <span id="openai_compatible_tts_speed_output"></span></label>
4040 <input type="range" id="openai_compatible_tts_speed" value="1" min="0.25" max="4" step="0.05">`;
4141 return html;
public/scripts/extensions/tts/system.js+0 -3
@@ -1,6 +1,5 @@
11import { isMobile } from '../../RossAscends-mods.js';
22import { getPreviewString } from './index.js';
3-import { talkingAnimation } from './index.js';
43import { saveTtsProviderSettings } from './index.js';
54export { SystemTtsProvider };
65
@@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) {
7069 //placing the speak invocation inside a callback fixes ordering and onend issues.
7170 setTimeout(function () {
7271 speechSynthesis.speak(newUtt);
73- talkingAnimation(true);
7472 }, 0);
7573};
7674
@@ -240,7 +238,6 @@ class SystemTtsProvider {
240238 //some code to execute when done
241239 resolve(silence);
242240 console.log('System TTS done');
243- talkingAnimation(false);
244241 });
245242 });
246243 }
public/scripts/extensions/vectors/index.js+58 -64
@@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) {
561561 */
562562async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {
563563 try {
564564 if (settings.translate_files && typeof window['globalThis.translate'] === 'function') {
565565 console.log(`Vectors: Translating file ${fileName} to English...`);
566566 const translatedText = await window['globalThis.translate'](fileText, 'en');
567567 fileText = translatedText;
568568 }
569569
@@ -746,6 +746,44 @@ async function getQueryText(chat, initiator) {
746746}
747747
748748/**
749+ * Gets common body parameters for vector requests.
750+ * @returns {object}
751+ */
752+function getVectorsRequestBody() {
753+ const body = {};
754+ switch (settings.source) {
755+ case 'extras':
756+ body.extrasUrl = extension_settings.apiUrl;
757+ body.extrasKey = extension_settings.apiKey;
758+ break;
759+ case 'togetherai':
760+ body.model = extension_settings.vectors.togetherai_model;
761+ break;
762+ case 'openai':
763+ body.model = extension_settings.vectors.openai_model;
764+ break;
765+ case 'cohere':
766+ body.model = extension_settings.vectors.cohere_model;
767+ break;
768+ case 'ollama':
769+ body.model = extension_settings.vectors.ollama_model;
770+ body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.OLLAMA];
771+ body.keep = !!extension_settings.vectors.ollama_keep;
772+ break;
773+ case 'llamacpp':
774+ body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP];
775+ break;
776+ case 'vllm':
777+ body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.VLLM];
778+ body.model = extension_settings.vectors.vllm_model;
779+ break;
780+ default:
781+ break;
782+ }
783+ return body;
784+}
785+
786+/**
749787 * Gets the saved hashes for a collection
750788* @param {string} collectionId
751789* @returns {Promise<number[]>} Saved hashes
@@ -753,8 +791,9 @@ async function getQueryText(chat, initiator) {
753791async function getSavedHashes(collectionId) {
754792 const response = await fetch('/api/vector/list', {
755793 method: 'POST',
756794 headers: getVectorHeadersgetRequestHeaders(),
757795 body: JSON.stringify({
796+ ...getVectorsRequestBody(),
758797 collectionId: collectionId,
759798 source: settings.source,
760799 }),
@@ -768,54 +807,6 @@ async function getSavedHashes(collectionId) {
768807 return hashes;
769808}
770809
771-function getVectorHeaders() {
772- const headers = getRequestHeaders();
773- switch (settings.source) {
774- case 'extras':
775- Object.assign(headers, {
776- 'X-Extras-Url': extension_settings.apiUrl,
777- 'X-Extras-Key': extension_settings.apiKey,
778- });
779- break;
780- case 'togetherai':
781- Object.assign(headers, {
782- 'X-Togetherai-Model': extension_settings.vectors.togetherai_model,
783- });
784- break;
785- case 'openai':
786- Object.assign(headers, {
787- 'X-OpenAI-Model': extension_settings.vectors.openai_model,
788- });
789- break;
790- case 'cohere':
791- Object.assign(headers, {
792- 'X-Cohere-Model': extension_settings.vectors.cohere_model,
793- });
794- break;
795- case 'ollama':
796- Object.assign(headers, {
797- 'X-Ollama-Model': extension_settings.vectors.ollama_model,
798- 'X-Ollama-URL': textgenerationwebui_settings.server_urls[textgen_types.OLLAMA],
799- 'X-Ollama-Keep': !!extension_settings.vectors.ollama_keep,
800- });
801- break;
802- case 'llamacpp':
803- Object.assign(headers, {
804- 'X-LlamaCpp-URL': textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP],
805- });
806- break;
807- case 'vllm':
808- Object.assign(headers, {
809- 'X-Vllm-URL': textgenerationwebui_settings.server_urls[textgen_types.VLLM],
810- 'X-Vllm-Model': extension_settings.vectors.vllm_model,
811- });
812- break;
813- default:
814- break;
815- }
816- return headers;
817-}
818-
819810/**
820811 * Inserts vector items into a collection
821812 * @param {string} collectionId - The collection to insert into
@@ -825,12 +816,11 @@ function getVectorHeaders() {
825816async function insertVectorItems(collectionId, items) {
826817 throwIfSourceInvalid();
827818
828- const headers = getVectorHeaders();
829-
830819 const response = await fetch('/api/vector/insert', {
831820 method: 'POST',
832821 headers: headersgetRequestHeaders(),
833822 body: JSON.stringify({
823+ ...getVectorsRequestBody(),
834824 collectionId: collectionId,
835825 items: items,
836826 source: settings.source,
@@ -879,8 +869,9 @@ function throwIfSourceInvalid() {
879869async function deleteVectorItems(collectionId, hashes) {
880870 const response = await fetch('/api/vector/delete', {
881871 method: 'POST',
882872 headers: getVectorHeadersgetRequestHeaders(),
883873 body: JSON.stringify({
874+ ...getVectorsRequestBody(),
884875 collectionId: collectionId,
885876 hashes: hashes,
886877 source: settings.source,
@@ -899,12 +890,11 @@ async function deleteVectorItems(collectionId, hashes) {
899890 * @returns {Promise<{ hashes: number[], metadata: object[]}>} - Hashes of the results
900891 */
901892async function queryCollection(collectionId, searchText, topK) {
902- const headers = getVectorHeaders();
903-
904893 const response = await fetch('/api/vector/query', {
905894 method: 'POST',
906895 headers: headersgetRequestHeaders(),
907896 body: JSON.stringify({
897+ ...getVectorsRequestBody(),
908898 collectionId: collectionId,
909899 searchText: searchText,
910900 topK: topK,
@@ -929,12 +919,11 @@ async function queryCollection(collectionId, searchText, topK) {
929919 * @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - Results mapped to collection IDs
930920 */
931921async function queryMultipleCollections(collectionIds, searchText, topK, threshold) {
932- const headers = getVectorHeaders();
933-
934922 const response = await fetch('/api/vector/query-multi', {
935923 method: 'POST',
936924 headers: headersgetRequestHeaders(),
937925 body: JSON.stringify({
926+ ...getVectorsRequestBody(),
938927 collectionIds: collectionIds,
939928 searchText: searchText,
940929 topK: topK,
@@ -965,8 +954,9 @@ async function purgeFileVectorIndex(fileUrl) {
965954
966955 const response = await fetch('/api/vector/purge', {
967956 method: 'POST',
968957 headers: getVectorHeadersgetRequestHeaders(),
969958 body: JSON.stringify({
959+ ...getVectorsRequestBody(),
970960 collectionId: collectionId,
971961 }),
972962 });
@@ -994,8 +984,9 @@ async function purgeVectorIndex(collectionId) {
994984
995985 const response = await fetch('/api/vector/purge', {
996986 method: 'POST',
997987 headers: getVectorHeadersgetRequestHeaders(),
998988 body: JSON.stringify({
989+ ...getVectorsRequestBody(),
999990 collectionId: collectionId,
1000991 }),
1001992 });
@@ -1019,7 +1010,10 @@ async function purgeAllVectorIndexes() {
10191010 try {
10201011 const response = await fetch('/api/vector/purge-all', {
10211012 method: 'POST',
10221013 headers: getVectorHeadersgetRequestHeaders(),
1014+ body: JSON.stringify({
1015+ ...getVectorsRequestBody(),
1016+ }),
10231017 });
10241018
10251019 if (!response.ok) {
public/scripts/group-chats.js+5 -2
@@ -1664,12 +1664,12 @@ function updateFavButtonState(state) {
16641664export async function openGroupById(groupId) {
16651665 if (isChatSaving) {
16661666 toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`);
16671667 return false;
16681668 }
16691669
16701670 if (!groups.find(x => x.id === groupId)) {
16711671 console.log('Group not found', groupId);
16721672 return false;
16731673 }
16741674
16751675 if (!is_send_press && !is_group_generating) {
@@ -1686,8 +1686,11 @@ export async function openGroupById(groupId) {
16861686 updateChatMetadata({}, true);
16871687 chat.length = 0;
16881688 await getGroupChat(groupId);
1689+ return true;
16891690 }
16901691 }
1692+
1693+ return false;
16911694}
16921695
16931696function openCharacterDefinition(characterSelect) {
public/scripts/openai.js+45 -28
@@ -2167,6 +2167,14 @@ function getStreamingReply(data, state) {
21672167 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
21682168 }
21692169 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 ?? '';
21702178 } else {
21712179 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
21722180 }
@@ -4107,6 +4115,40 @@ function getMaxContextWindowAI(value) {
41074115 }
41084116}
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+ */
4124+function 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+
41104152async function onModelChange() {
41114153 biasCache = undefined;
41124154 let value = String($(this).val() || '');
@@ -4387,7 +4429,7 @@ async function onModelChange() {
43874429 if (oai_settings.max_context_unlocked) {
43884430 $('#openai_max_context').attr('max', unlocked_max);
43894431 }
43904432 else if (['sonar', 'sonar-reasoning', 'sonar-reasoning-pro', 'r1-1776'].includes(oai_settings.perplexity_model)) {
43914433 $('#openai_max_context').attr('max', 127000);
43924434 }
43934435 else if (['sonar-pro'].includes(oai_settings.perplexity_model)) {
@@ -4408,33 +4450,8 @@ async function onModelChange() {
44084450 }
44094451
44104452 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);
44124454 $('#openai_max_context').attr('max', unlocked_maxmaxContext);
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- }
44384455 oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
44394456 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
44404457 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
18461846/**
18471847 * Common function to perform fuzzy search with optional caching
1848+ * @template T
18481849 * @param {string} type - Type of search from fuzzySearchCategories
18491850 * @param {anyT[]} data - Data array to search in
18501851 * @param {Array<{name: string, weight: number, getFn?: (obj: anyT) => string}>} keys - Fuse.js keys configuration
18511852 * @param {string} searchValue - The search term
18521853 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
18531854 * @returns {import('fuse.js').FuseResult<anyT>[]} Results as items with their score
18541855 */
18551856export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
18561857 // Check cache if provided
18571858 if (fuzzySearchCaches) {
18581859 const cache = fuzzySearchCaches[type];
public/scripts/reasoning.js+192 -37
@@ -1,19 +1,32 @@
11import {
22 moment,
33} from '../lib.js';
44import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveChatDebounced, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js';
55import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
66import { getCurrentLocale, t, translate } from './i18n.js';
77import { MacrosParser } from './macros.js';
88import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';
99import { Popup } from './popup.js';
1010import { power_user } from './power-user.js';
1111import { SlashCommand } from './slash-commands/SlashCommand.js';
1212import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
1313import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
14+import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
1415import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
1516import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
1617import { 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+ */
24+export const ReasoningType = {
25+ Model: 'model',
26+ Parsed: 'parsed',
27+ Manual: 'manual',
28+ Edited: 'edited',
29+};
1730
1831/**
1932 * Gets a message from a jQuery element.
@@ -63,6 +76,11 @@ export function extractReasoningFromData(data) {
6376 return data?.choices?.[0]?.message?.reasoning ?? '';
6477 case chat_completion_sources.MAKERSUITE:
6578 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+ }
6684 }
6785 break;
6886 }
@@ -94,7 +112,7 @@ export function isHiddenReasoningModel() {
94112 { name: 'gemini-2.0-pro-exp', func: FUNCS.startsWith },
95113 ];
96114
97115 const model = getChatCompletionModel() || '';
98116
99117 const isHidden = hiddenReasoningModels.some(({ name, func }) => func(model, name));
100118 return isHidden;
@@ -129,7 +147,12 @@ export const ReasoningState = {
129147 * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates.
130148 */
131149export class ReasoningHandler {
150+ /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
132151 #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
134157 /**
135158 * @param {Date?} [timeStarted=null] - When the generation started
@@ -137,6 +160,8 @@ export class ReasoningHandler {
137160 constructor(timeStarted = null) {
138161 /** @type {ReasoningState} The current state of the reasoning process */
139162 this.state = ReasoningState.None;
163+ /** @type {ReasoningType?} The type of the reasoning (where it came from) */
164+ this.type = null;
140165 /** @type {string} The reasoning output */
141166 this.reasoning = '';
142167 /** @type {Date} When the reasoning started */
@@ -147,7 +172,6 @@ export class ReasoningHandler {
147172 /** @type {Date} Initial starting time of the generation */
148173 this.initialTime = timeStarted ?? new Date();
149174
150- /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
151175 this.#isHiddenReasoningModel = isHiddenReasoningModel();
152176
153177 // Cached DOM elements for reasoning
@@ -194,6 +218,7 @@ export class ReasoningHandler {
194218 this.state = ReasoningState.Hidden;
195219 }
196220
221+ this.type = extra?.reasoning_type;
197222 this.reasoning = extra?.reasoning ?? '';
198223
199224 if (this.state !== ReasoningState.None) {
@@ -208,6 +233,7 @@ export class ReasoningHandler {
208233 // Make sure reset correctly clears all relevant states
209234 if (reset) {
210235 this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None;
236+ this.type = null;
211237 this.reasoning = '';
212238 this.initialTime = new Date();
213239 this.startTime = null;
@@ -237,18 +263,19 @@ export class ReasoningHandler {
237263 * Updates the reasoning text/string for a message.
238264 *
239265 * @param {number} messageId - The ID of the message to update
240266 * @param {string?} [reasoning=null] - The reasoning text to update - If null or empty, uses the current reasoning
241267 * @param {Object} [options={}] - Optional arguments
242268 * @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
243270 * @returns {boolean} - Returns true if the reasoning was changed, otherwise false
244271 */
245272 updateReasoning(messageId, reasoning = null, { persist = false, allowReset = false } = {}) {
246273 if (messageId == -1 || !chat[messageId]) {
247274 return false;
248275 }
249276
250277 reasoning = allowReset ? reasoning ?? this.reasoning : reasoning || this.reasoning;
251278 reasoning = power_user.trim_spaces ? reasoning.trimtrimSpaces() : reasoning);
252279
253280 // Ensure the chat extra exists
254281 if (!chat[messageId].extra) {
@@ -259,10 +286,13 @@ export class ReasoningHandler {
259286 const reasoningChanged = extra.reasoning !== reasoning;
260287 this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING);
261288
289+ this.type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
290+
262291 if (persist) {
263292 // Build and save the reasoning data to message extras
264293 extra.reasoning = this.reasoning;
265294 extra.reasoning_duration = this.getDuration();
295+ extra.reasoning_type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
266296 }
267297
268298 return reasoningChanged;
@@ -279,7 +309,10 @@ export class ReasoningHandler {
279309 * @returns {Promise<void>}
280310 */
281311 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
284317 // Ensure reasoning string is updated and regexes are applied correctly
285318 const reasoningChanged = this.updateReasoning(messageId, null, { persist: true });
@@ -294,6 +327,54 @@ export class ReasoningHandler {
294327 }
295328 }
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+
297378 /**
298379 * Completes the reasoning process for a message.
299380 *
@@ -336,9 +417,10 @@ export class ReasoningHandler {
336417 // Update states to the relevant DOM elements
337418 setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null);
338419 setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state);
420+ setDatasetProperty(this.messageReasoningDetailsDom, 'type', this.type);
339421
340422 // Update the reasoning message
341423 const reasoning = power_user.trim_spaces ? this.reasoning.trimtrimSpaces() : this.reasoning);
342424 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
343425 this.messageReasoningContentDom.innerHTML = displayReasoning;
344426
@@ -393,17 +475,14 @@ export class ReasoningHandler {
393475 const element = this.messageReasoningHeaderDom;
394476 const duration = this.getDuration();
395477 let data = null;
478+ let title = '';
396479 if (duration) {
397480 const durationStrseconds = moment.duration(duration).locale(getCurrentLocale()).humanizeasSeconds({ s: 50, ss: 3 });
398- const secondsStr = moment.duration(duration).asSeconds();
399481
400- const span = document.createElement('span');
482+ const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });
401483 spanelement.titletextContent = t`Thought for ${secondsStrdurationStr} seconds`;
402484 span.textContentdata = durationStrString(seconds);
403-
485+ title = `${seconds} seconds`;
404- element.textContent = t`Thought for `;
405- element.appendChild(span);
406- data = String(secondsStr);
407486 } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) {
408487 element.textContent = t`Thought for some time`;
409488 data = 'unknown';
@@ -412,6 +491,12 @@ export class ReasoningHandler {
412491 data = null;
413492 }
414493
494+ if (this.type !== ReasoningType.Model) {
495+ title += ` [${translate(this.type)}]`;
496+ title = title.trim();
497+ }
498+ element.title = title;
499+
415500 setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data);
416501 setDatasetProperty(element, 'duration', data);
417502 }
@@ -573,11 +658,16 @@ function registerReasoningSlashCommands() {
573658 callback: async (args, value) => {
574659 const messageId = !isNaN(Number(args.at)) ? Number(args.at) : chat.length - 1;
575660 const message = chat[messageId];
576661 if (!message?.extra) {
577662 return '';
578663 }
664+ // Make sure the message has an extra object
665+ if (!message.extra || typeof message.extra !== 'object') {
666+ message.extra = {};
667+ }
579668
580669 message.extra.reasoning = String(value ?? '');
670+ message.extra.reasoning_type = ReasoningType.Manual;
581671 await saveChatConditional();
582672
583673 closeMessageEditor('reasoning');
@@ -598,7 +688,26 @@ function registerReasoningSlashCommands() {
598688 typeList: [ARGUMENT_TYPE.BOOLEAN],
599689 defaultValue: 'true',
600690 isRequired: false,
601691 enumProviderenumList: 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')(),
602711 }),
603712 ],
604713 unnamedArgumentList: [
@@ -608,19 +717,27 @@ function registerReasoningSlashCommands() {
608717 }),
609718 ],
610719 callback: (args, value) => {
611720 if (!value || typeof value !== 'string') {
612721 return '';
613722 }
614723
615724 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
616725 toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Parse`);
617726 return String(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`);
618730 }
619731
620- const parsedReasoning = parseReasoningFromString(String(value));
732+ const returnMessage = args.return === 'content';
621733
734+ const parsedReasoning = parseReasoningFromString(value, { strict: !isFalseBoolean(String(args.strict ?? '')) });
622735 if (!parsedReasoning) {
623736 return returnMessage ? value : '';
737+ }
738+
739+ if (returnMessage) {
740+ return parsedReasoning.content;
624741 }
625742
626743 const applyRegex = !isFalseBoolean(String(args.regex ?? ''));
@@ -638,6 +755,17 @@ function registerReasoningMacros() {
638755}
639756
640757function 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+
641769 $(document).on('click', '.mes_reasoning_details', function (e) {
642770 if (!e.target.closest('.mes_reasoning_actions') && !e.target.closest('.mes_reasoning_header')) {
643771 e.preventDefault();
@@ -718,8 +846,7 @@ function setReasoningEventHandlers() {
718846 }
719847
720848 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;
723850 await saveChatConditional();
724851 updateMessageBlock(messageId, message);
725852 textarea.remove();
@@ -780,6 +907,8 @@ function setReasoningEventHandlers() {
780907 return;
781908 }
782909 message.extra.reasoning = '';
910+ delete message.extra.reasoning_type;
911+ delete message.extra.reasoning_duration;
783912 await saveChatConditional();
784913 updateMessageBlock(messageId, message);
785914 const textarea = messageBlock.find('.reasoning_edit_textarea');
@@ -797,6 +926,20 @@ function setReasoningEventHandlers() {
797926 await copyText(reasoning);
798927 toastr.info(t`Copied!`, '', { timeOut: 2000 });
799928 });
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+ });
800943}
801944
802945/**
@@ -819,16 +962,18 @@ export function removeReasoningFromString(str) {
819962 * @property {string} reasoning Reasoning block
820963 * @property {string} content Message content
821964 * @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
822967 * @returns {ParsedReasoning|null} Parsed reasoning block and message content
823968 */
824969function parseReasoningFromString(str, { strict = true } = {}) {
825970 // Both prefix and suffix must be defined
826971 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
827972 return null;
828973 }
829974
830975 try {
831976 const regex = new RegExp(`${(strict ? '^\\s*?' : '')}${escapeRegex(power_user.reasoning.prefix)}(.*?)${escapeRegex(power_user.reasoning.suffix)}`, 's');
832977
833978 let didReplace = false;
834979 let reasoning = '';
@@ -838,9 +983,9 @@ function parseReasoningFromString(str) {
838983 return '';
839984 });
840985
841986 if (didReplace && power_user.trim_spaces) {
842987 reasoning = reasoning.trimtrimSpaces(reasoning);
843988 content = content.trimtrimSpaces(content);
844989 }
845990
846991 return { reasoning, content };
@@ -851,7 +996,7 @@ function parseReasoningFromString(str) {
851996}
852997
853998function registerReasoningAppEvents() {
854999 eventSource.makeFirst(event_types.MESSAGE_RECEIVED,const eventHandler = (/** @type {number} */ idx) => {
8551000 if (!power_user.reasoning.auto_parse) {
8561001 return;
8571002 }
@@ -869,6 +1014,11 @@ function registerReasoningAppEvents() {
8691014 return null;
8701015 }
8711016
1017+ if (message.extra?.reasoning) {
1018+ console.debug('[Reasoning] Message already has reasoning', idx);
1019+ return null;
1020+ }
1021+
8721022 const parsedReasoning = parseReasoningFromString(message.mes);
8731023
8741024 // No reasoning block found
@@ -886,6 +1036,7 @@ function registerReasoningAppEvents() {
8861036 // If reasoning was found, add it to the message
8871037 if (parsedReasoning.reasoning) {
8881038 message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING);
1039+ message.extra.reasoning_type = ReasoningType.Parsed;
8891040 }
8901041
8911042 // Update the message text if it was changed
@@ -901,7 +1052,11 @@ function registerReasoningAppEvents() {
9011052 updateMessageBlock(idx, message);
9021053 }
9031054 }
9041055 });
1056+
1057+ for (const event of [event_types.MESSAGE_RECEIVED, event_types.MESSAGE_UPDATED]) {
1058+ eventSource.on(event, eventHandler);
1059+ }
9051060}
9061061
9071062export function initReasoning() {
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+1 -0
@@ -34,6 +34,7 @@ export const enumIcons = {
3434 preset: '⚙️',
3535 file: '📄',
3636 message: '💬',
37+ reasoning: '💡',
3738 voice: '🎤',
3839 server: '🖥️',
3940 popup: '🗔',
public/scripts/textgen-settings.js+2 -2
@@ -311,7 +311,7 @@ export function validateTextGenUrl() {
311311 const formattedUrl = formatTextGenURL(url);
312312
313313 if (!formattedUrl) {
314314 toastr.error('t`Enter a valid API URL'`, 'Text Completion API');
315315 return;
316316 }
317317
@@ -1187,7 +1187,7 @@ export function getTextGenModel() {
11871187 return settings.aphrodite_model;
11881188 case OLLAMA:
11891189 if (!settings.ollama_model) {
11901190 toastr.error('t`No Ollama model selected.'`, 'Text Completion API');
11911191 throw new Error('No Ollama model selected');
11921192 }
11931193 return settings.ollama_model;
public/scripts/tokenizers.js+4 -1
@@ -679,7 +679,7 @@ export function getTokenizerModel() {
679679 }
680680
681681 if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) {
682682 if (oai_settings.perplexity_model.includes('sonar-reasoning') || oai_settings.perplexity_model.includes('r1-1776')) {
683683 return deepseekTokenizer;
684684 }
685685 if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) {
@@ -694,6 +694,9 @@ export function getTokenizerModel() {
694694 }
695695
696696 if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) {
697+ if (oai_settings.groq_model.includes('qwen')) {
698+ return qwen2Tokenizer;
699+ }
697700 if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) {
698701 return llama3Tokenizer;
699702 }
public/scripts/user.js+26 -0
@@ -9,6 +9,9 @@ import { ensureImageFormatSupported, getBase64Async, humanFileSize } from './uti
99export let currentUser = null;
1010export let accountsEnabled = false;
1111
12+// Extend the session every 30 minutes
13+const SESSION_EXTEND_INTERVAL = 30 * 60 * 1000;
14+
1215/**
1316 * Enable or disable user account controls in the UI.
1417 * @param {boolean} isEnabled User account controls enabled
@@ -894,6 +897,24 @@ async function slugify(text) {
894897 }
895898}
896899
900+/**
901+ * Pings the server to extend the user session.
902+ */
903+async 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+
897918jQuery(() => {
898919 $('#logout_button').on('click', () => {
899920 logout();
@@ -904,4 +925,9 @@ jQuery(() => {
904925 $('#account_button').on('click', () => {
905926 openUserProfile();
906927 });
928+ setInterval(async () => {
929+ if (currentUser) {
930+ await extendUserSession();
931+ }
932+ }, SESSION_EXTEND_INTERVAL);
907933});
public/scripts/utils.js+24 -6
@@ -8,7 +8,7 @@ import {
88import { getContext } from './extensions.js';
99import { characters, getRequestHeaders, this_chid } from '../script.js';
1010import { isMobile } from './RossAscends-mods.js';
1111import { collapseNewlines, power_user } from './power-user.js';
1212import { debounce_timeout } from './constants.js';
1313import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
1414import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
@@ -677,6 +677,19 @@ export function sortByCssOrder(a, b) {
677677}
678678
679679/**
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+
685+export 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+/**
680693 * Trims a string to the end of a nearest sentence.
681694 * @param {string} input The string to trim.
682695 * @returns {string} The trimmed string.
@@ -994,13 +1007,18 @@ export function getImageSizeFromDataURL(dataUrl) {
9941007 });
9951008}
9961009
997-export 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+ */
1017+export function getCharaFilename(chid = null, { manualAvatarKey = null } = {}) {
9981018 const context = getContext();
9991019 const fileName = manualAvatarKey ?? context.characters[chid ?? context.characterId]?.avatar;
10001020
1001- if (fileName) {
1021+ return fileName?.replace(/\.[^/.]+$/, '') ?? null;
1002- return fileName.replace(/\.[^/.]+$/, '');
1003- }
10041022}
10051023
10061024/**
public/style.css+23 -21
@@ -55,6 +55,10 @@
5555 --interactable-outline-color: var(--white100);
5656 --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
5963 /*Default Theme, will be changed by ToolCool Color Picker*/
6064 --SmartThemeBodyColor: rgb(220, 220, 210);
@@ -348,13 +352,13 @@ input[type='checkbox']:focus-visible {
348352
349353.mes_reasoning {
350354 display: block;
351355 border-left: 2px solid var(--SmartThemeEmColorreasoning-body-color);
352356 border-radius: 2px;
353357 padding: 5px;
354358 padding-left: 14px;
355359 margin-bottom: 0.5em;
356360 overflow-y: auto;
357- color: var(--SmartThemeEmColor);
361+ color: hsl(from var(--reasoning-body-color) h calc(s * var(--reasoning-saturation)) l);
358362}
359363
360364.mes_reasoning_details {
@@ -374,18 +378,6 @@ input[type='checkbox']:focus-visible {
374378 margin-bottom: 0;
375379}
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-
389381.mes_reasoning_header_block {
390382 flex-grow: 1;
391383}
@@ -438,7 +430,7 @@ input[type='checkbox']:focus-visible {
438430}
439431
440432/** If hidden reasoning should not be shown, we hide all blocks that don't have content */
441433#chat:not([data-show-hidden-reasoning="true"]):not(:has(.reasoning_edit_textarea)) .mes:has(.mes_reasoning:empty) .mes_reasoning_details {
442434 display: none;
443435}
444436
@@ -461,26 +453,36 @@ input[type='checkbox']:focus-visible {
461453}
462454
463455.mes_text i,
464456.mes_text em, {
457+ color: var(--SmartThemeEmColor);
458+}
465459.mes_reasoning i,
466460.mes_reasoning em {
467- color: var(--SmartThemeEmColor);
461+ color: hsl(from var(--reasoning-em-color) h calc(s * var(--reasoning-saturation)) l);
468462}
469463
470464.mes_text q i,
471465.mes_text q em {
472466 color: inherit;
473467}
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
475473.mes_text u, {
476-.mes_reasoning u {
477474 color: var(--SmartThemeUnderlineColor);
478475}
476+.mes_reasoning u {
477+ color: hsl(from var(--SmartThemeUnderlineColor) h calc(s * var(--reasoning-saturation)) l);
478+}
479479
480480.mes_text q, {
481-.mes_reasoning q {
482481 color: var(--SmartThemeQuoteColor);
483482}
483+.mes_reasoning q {
484+ color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
485+}
484486
485487.mes_text font[color] em,
486488.mes_text font[color] i,
server.js+20 -5
@@ -58,6 +58,7 @@ import {
5858import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
5959import basicAuthMiddleware from './src/middleware/basicAuth.js';
6060import whitelistMiddleware from './src/middleware/whitelist.js';
61+import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
6162import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
6263import initRequestProxy from './src/request-proxy.js';
6364import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
@@ -243,7 +244,6 @@ const cliArguments = yargs(hideBin(process.argv))
243244 describe: 'Request proxy URL (HTTP or SOCKS protocols)',
244245 }).option('requestProxyBypass', {
245246 type: 'array',
246- default: null,
247247 describe: 'Request proxy bypass list (space separated list of hosts)',
248248 }).parseSync();
249249
@@ -340,9 +340,17 @@ const CORS = cors({
340340
341341app.use(CORS);
342342
343343if (listen && basicAuthMode) app.use(basicAuthMiddleware);{
344+ app.use(basicAuthMiddleware);
345+}
346+
347+if (enableWhitelist) {
348+ app.use(whitelistMiddleware());
349+}
344350
345-app.use(whitelistMiddleware(enableWhitelist, listen));
351+if (listen) {
352+ app.use(accessLoggerMiddleware());
353+}
346354
347355if (enableCorsProxy) {
348356 app.use(bodyParser.json({
@@ -556,7 +564,13 @@ app.use('/api/users', usersPublicRouter);
556564
557565// Everything below this line requires authentication
558566app.use(requireLoginMiddleware);
559567app.get('/api/ping', (_request, response) => response.sendStatus(204));{
568+ if (request.query.extend && request.session) {
569+ request.session.touch = Date.now();
570+ }
571+
572+ response.sendStatus(204);
573+});
560574
561575// File uploads
562576app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
@@ -754,6 +768,7 @@ const preSetupTasks = async function () {
754768 await checkForNewContent(directories);
755769 await ensureThumbnailCache();
756770 cleanUploads();
771+ migrateAccessLog();
757772
758773 await settingsInit();
759774 await statsInit();
@@ -856,7 +871,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
856871 if (listen) {
857872 console.log();
858873 console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
859874 console.log('Check the "access.log" file in the SillyTaverndata directory to inspect incoming connections.:', color.green(getAccessLogPath()));
860875 }
861876 console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
862877 console.log(goToLog);
src/endpoints/backends/chat-completions.js+1 -4
@@ -979,6 +979,7 @@ router.post('/generate', jsonParser, function (request, response) {
979979 headers = { ...OPENROUTER_HEADERS };
980980 bodyParams = {
981981 'transforms': getOpenRouterTransforms(request),
982+ 'include_reasoning': Boolean(request.body.include_reasoning),
982983 };
983984
984985 if (request.body.min_p !== undefined) {
@@ -1004,10 +1005,6 @@ router.post('/generate', jsonParser, function (request, response) {
10041005 bodyParams['route'] = 'fallback';
10051006 }
10061007
1007- if (request.body.include_reasoning) {
1008- bodyParams['include_reasoning'] = true;
1009- }
1010-
10111008 let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1);
10121009 if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && request.body.model?.startsWith('anthropic/claude-3')) {
10131010 cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth);
src/endpoints/extensions.js+1 -1
@@ -230,7 +230,7 @@ router.post('/version', jsonParser, async (request, response) => {
230230 } catch (error) {
231231 // it is not a git repo, or has no commits yet, or is a bare repo
232232 // not possible to update it, most likely can't get the branch name either
233233 return response.send({ currentBranchName: null'', currentCommitHash: '', isUpToDate: true, remoteUrl: null'' });
234234 }
235235
236236 const currentBranch = await git.cwd(extensionPath).branch();
src/endpoints/sprites.js+13 -5
@@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
125125 .map((file) => {
126126 const pathToSprite = path.join(spritesPath, file);
127127 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+
128134 return {
129- label: path.parse(pathToSprite).name.toLowerCase(),
135+ label: label,
130136 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
131137 };
132138 });
@@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) {
141147router.post('/delete', jsonParser, async (request, response) => {
142148 const label = request.body.label;
143149 const name = request.body.name;
150+ const spriteName = request.body.spriteName || label;
144151
145152 if (!labelspriteName || !name) {
146153 return response.sendStatus(400);
147154 }
148155
@@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => {
158165
159166 // Remove existing sprite with the same label
160167 for (const file of files) {
161168 if (path.parse(file).name === labelspriteName) {
162169 fs.rmSync(path.join(spritesPath, file));
163170 }
164171 }
@@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => {
221228 const file = request.file;
222229 const label = request.body.label;
223230 const name = request.body.name;
231+ const spriteName = request.body.spriteName || label;
224232
225233 if (!file || !label || !name) {
226234 return response.sendStatus(400);
@@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => {
243251
244252 // Remove existing sprite with the same label
245253 for (const file of files) {
246254 if (path.parse(file).name === labelspriteName) {
247255 fs.rmSync(path.join(spritesPath, file));
248256 }
249257 }
250258
251259 const filename = labelspriteName + path.parse(file.originalname).ext;
252260 const spritePath = path.join(file.destination, file.filename);
253261 const pathToFile = path.join(spritesPath, filename);
254262 // Copy uploaded file to sprites folder
src/endpoints/users-public.js+10 -7
@@ -3,13 +3,16 @@ import crypto from 'node:crypto';
33import storage from 'node-persist';
44import express from 'express';
55import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
66import { jsonParser, getIpFromRequest, getRealIpFromHeader } from '../express-common.js';
77import { color, Cache, getConfigValue } from '../util.js';
88import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';
99
1010const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);
11+const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false);
1112const MFA_CACHE = new Cache(5 * 60 * 1000);
1213
14+const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
15+
1316export const router = express.Router();
1417const loginLimiter = new RateLimiterMemory({
1518 points: 5,
@@ -60,7 +63,7 @@ router.post('/login', jsonParser, async (request, response) => {
6063 return response.status(400).json({ error: 'Missing required fields' });
6164 }
6265
6366 const ip = getIpFromRequestgetIpAddress(request);
6467 await loginLimiter.consume(ip);
6568
6669 /** @type {import('../users.js').User} */
@@ -92,7 +95,7 @@ router.post('/login', jsonParser, async (request, response) => {
9295 return response.json({ handle: user.handle });
9396 } catch (error) {
9497 if (error instanceof RateLimiterRes) {
9598 console.error('Login failed: Rate limited from', getIpFromRequestgetIpAddress(request));
9699 return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
97100 }
98101
@@ -108,7 +111,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => {
108111 return response.status(400).json({ error: 'Missing required fields' });
109112 }
110113
111114 const ip = getIpFromRequestgetIpAddress(request);
112115 await recoverLimiter.consume(ip);
113116
114117 /** @type {import('../users.js').User} */
@@ -132,7 +135,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => {
132135 return response.sendStatus(204);
133136 } catch (error) {
134137 if (error instanceof RateLimiterRes) {
135138 console.error('Recover step 1 failed: Rate limited from', getIpFromRequestgetIpAddress(request));
136139 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
137140 }
138141
@@ -150,7 +153,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => {
150153
151154 /** @type {import('../users.js').User} */
152155 const user = await storage.getItem(toKey(request.body.handle));
153156 const ip = getIpFromRequestgetIpAddress(request);
154157
155158 if (!user) {
156159 console.error('Recover step 2 failed: User', request.body.handle, 'not found');
@@ -186,7 +189,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => {
186189 return response.sendStatus(204);
187190 } catch (error) {
188191 if (error instanceof RateLimiterRes) {
189192 console.error('Recover step 2 failed: Rate limited from', getIpFromRequestgetIpAddress(request));
190193 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
191194 }
192195
src/endpoints/vectors.js+11 -11
@@ -132,35 +132,35 @@ function getSourceSettings(source, request) {
132132 switch (source) {
133133 case 'togetherai':
134134 return {
135135 model: String(request.headers['x-togetherai-body.model']),
136136 };
137137 case 'openai':
138138 return {
139139 model: String(request.headers['x-openai-body.model']),
140140 };
141141 case 'cohere':
142142 return {
143143 model: String(request.headers['x-cohere-body.model']),
144144 };
145145 case 'llamacpp':
146146 return {
147147 apiUrl: String(request.headers['x-llamacpp-url']body.apiUrl),
148148 };
149149 case 'vllm':
150150 return {
151151 apiUrl: String(request.headers['x-vllm-url']body.apiUrl),
152152 model: String(request.headers['x-vllm-body.model']),
153153 };
154154 case 'ollama':
155155 return {
156156 apiUrl: String(request.headers['x-ollama-url']body.apiUrl),
157157 model: String(request.headers['x-ollama-body.model']),
158158 keep: Boolean(request.headers['x-ollama-body.keep']),
159159 };
160160 case 'extras':
161161 return {
162162 extrasUrl: String(request.headers['x-extras-url']body.extrasUrl),
163163 extrasKey: String(request.headers['x-extras-key']body.extrasKey),
164164 };
165165 case 'transformers':
166166 return {
src/express-common.js+14 -0
@@ -25,3 +25,17 @@ export function getIpFromRequest(req) {
2525 }
2626 return clientIp;
2727}
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+ */
35+export 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 @@
1+import path from 'node:path';
2+import fs from 'node:fs';
3+import { getRealIpFromHeader } from '../express-common.js';
4+import { color, getConfigValue } from '../util.js';
5+
6+const enableAccessLog = getConfigValue('logging.enableAccessLog', true);
7+
8+const knownIPs = new Set();
9+
10+export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log');
11+
12+export 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+ */
33+export 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 @@
11import path from 'node:path';
22import webpack from 'webpack';
33import { publicLibConfig }getPublicLibConfig from '../../webpack.config.js';
44
55export default function getWebpackServeMiddleware() {
6- const outputPath = publicLibConfig.output?.path;
7- const outputFile = publicLibConfig.output?.filename;
8-
96 /**
107 * A very spartan recreation of webpack-dev-middleware.
118 * @param {import('express').Request} req Request object.
@@ -14,6 +11,10 @@ export default function getWebpackServeMiddleware() {
1411 * @type {import('express').RequestHandler}
1512 */
1613 function devMiddleware(req, res, next) {
14+ const publicLibConfig = getPublicLibConfig();
15+ const outputPath = publicLibConfig.output?.path;
16+ const outputFile = publicLibConfig.output?.filename;
17+
1718 if (req.method === 'GET' && path.parse(req.path).base === outputFile) {
1819 return res.sendFile(outputFile, { root: outputPath });
1920 }
@@ -23,9 +24,12 @@ export default function getWebpackServeMiddleware() {
2324
2425 /**
2526 * Wait until Webpack is done compiling.
27+ * @param {object} param Parameters.
28+ * @param {boolean} [param.forceDist] Whether to force the use the /dist folder.
2629 * @returns {Promise<void>}
2730 */
2831 devMiddleware.runWebpackCompiler = ({ forceDist = false } = {}) => {
32+ const publicLibConfig = getPublicLibConfig(forceDist);
2933 const compiler = webpack(publicLibConfig);
3034
3135 return new Promise((resolve) => {
src/middleware/whitelist.js+16 -25
@@ -10,7 +10,6 @@ import { color, getConfigValue, safeReadFileSync } from '../util.js';
1010const whitelistPath = path.join(process.cwd(), './whitelist.txt');
1111const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
1212let whitelist = getConfigValue('whitelist', []);
13-let knownIPs = new Set();
1413
1514if (fs.existsSync(whitelistPath)) {
1615 try {
@@ -48,47 +47,39 @@ function getForwardedIp(req) {
4847
4948/**
5049 * 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
5350 * @returns {import('express').RequestHandler} The middleware function
5451 */
5552export default function whitelistMiddleware(whitelistMode, listen) {
5653 const forbiddenWebpage = Handlebars.compile(
5754 safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
5855 );
5956
57+ const noLogPaths = [
58+ '/favicon.ico',
59+ ];
60+
6061 return function (req, res, next) {
6162 const clientIp = getIpFromRequest(req);
6263 const forwardedIp = getForwardedIp(req);
6364 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-
7966 //clientIp = req.connection.remoteAddress.split(':').pop();
8067 if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))
8168 || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
8269 ) {
8370 // Log the connection attempt with real IP address
8471 const ipDetails = forwardedIp
8572 ? `${clientIp} (forwarded from ${forwardedIp})`
8673 : clientIp;
87- console.warn(
74+
88- color.red(
75+ if (!noLogPaths.includes(req.path)) {
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`,
76+ console.warn(
90- ),
77+ color.red(
91- );
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`,
79+ ),
80+ );
81+ }
82+
9283 return res.status(403).send(forbiddenWebpage({ ipDetails }));
9384 }
9485 next();
src/plugin-loader.js+2 -2
@@ -3,7 +3,7 @@ import path from 'node:path';
33import url from 'node:url';
44
55import express from 'express';
66import { default as git, CheckRepoActions } from 'simple-git';
77import { sync as commandExistsSync } from 'command-exists';
88import { getConfigValue, color } from './util.js';
99
@@ -256,7 +256,7 @@ async function updatePlugins(pluginsPath) {
256256 const pluginPath = path.join(pluginsPath, directory);
257257 const pluginRepo = git(pluginPath);
258258
259259 const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
260260 if (!isRepo) {
261261 continue;
262262 }
src/util.js+1 -1
@@ -763,7 +763,7 @@ export function stringToBool(str) {
763763 * Setup the minimum log level
764764 */
765765export function setupLogLevel() {
766766 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG);
767767
768768 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};
769769 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};
webpack.config.js+69 -32
@@ -1,35 +1,72 @@
11import process from 'node:process';
22import path from 'node:path';
3+import isDocker from 'is-docker';
34
4-/** @type {import('webpack').Configuration} */
5+/**
5-export const publicLibConfig = {
6+ * Get the Webpack configuration for the public/lib.js file.
6- mode: 'production',
7+ * 1. Docker has got cache and the output file pre-baked.
7- entry: './public/lib.js',
8+ * 2. Non-Docker environments use the global DATA_ROOT variable to determine the cache and output directories.
8- cache: {
9+ * @param {boolean} forceDist Whether to force the use the /dist folder.
9- type: 'filesystem',
10+ * @returns {import('webpack').Configuration}
10- cacheDirectory: path.resolve(process.cwd(), 'dist/webpack'),
11+ * @throws {Error} If the DATA_ROOT variable is not set.
11- store: 'pack',
12+ * */
12- compression: 'gzip',
13+export default function getPublicLibConfig(forceDist = false) {
13- },
14+ function getCacheDirectory() {
14- devtool: false,
15+ if (forceDist || isDocker()) {
15- watch: false,
16+ return path.resolve(process.cwd(), 'dist/webpack');
16- module: {},
17+ }
17- stats: {
18+
18- preset: 'minimal',
19+ if (typeof globalThis.DATA_ROOT === 'string') {
19- assets: false,
20+ return path.resolve(globalThis.DATA_ROOT, '_webpack', 'cache');
20- modules: false,
21+ }
21- colors: true,
22+
22- timings: true,
23+ throw new Error('DATA_ROOT variable is not set.');
2324 },
24- experiments: {
25+
25- outputModule: true,
26+ function getOutputDirectory() {
26- },
27+ if (forceDist || isDocker()) {
27- performance: {
28+ return path.resolve(process.cwd(), 'dist');
28- hints: false,
29+ }
29- },
30+
30- output: {
31+ if (typeof globalThis.DATA_ROOT === 'string') {
3132 path: return path.resolve(processglobalThis.cwd()DATA_ROOT, 'dist_webpack'), 'output');
32- filename: 'lib.js',
33+ }
33- libraryTarget: 'module',
34+
34- },
35+ throw new Error('DATA_ROOT variable is not set.');
3536 };
37+
38+ const cacheDirectory = getCacheDirectory();
39+ const outputDirectory = getOutputDirectory();
40+
41+ return {
42+ mode: 'production',
43+ entry: './public/lib.js',
44+ cache: {
45+ type: 'filesystem',
46+ cacheDirectory: cacheDirectory,
47+ store: 'pack',
48+ compression: 'gzip',
49+ },
50+ devtool: false,
51+ watch: false,
52+ module: {},
53+ stats: {
54+ preset: 'minimal',
55+ assets: false,
56+ modules: false,
57+ colors: true,
58+ timings: true,
59+ },
60+ experiments: {
61+ outputModule: true,
62+ },
63+ performance: {
64+ hints: false,
65+ },
66+ output: {
67+ path: outputDirectory,
68+ filename: 'lib.js',
69+ libraryTarget: 'module',
70+ },
71+ };
72+}