Merge branch 'staging' into persona-improvements

8bd4fd76ae604bed6c4960757e74d5a83de10e92

Wolfsblvt <wolfsblvt@gmail.com>

150 files changed, +3863 -1742Showing whitespace changes
.github/ISSUE_TEMPLATE/bug-report.yml+2 -0
@@ -80,6 +80,8 @@ body:
8080 required: true
8181 - label: I have checked the [docs](https://docs.sillytavern.app/) ![important](https://img.shields.io/badge/Important!-F6094E)
8282 required: true
83+ - label: I confirm that my issue is not related to third-party content, unofficial extension or patch. If in doubt, check with a new [user account](https://docs.sillytavern.app/administration/multi-user/) and with extensions disabled
84+ required: true
8385
8486 - type: markdown
8587 attributes:
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+24 -0
@@ -6,7 +6,13 @@ cardsCacheCapacity: 100
66# -- SERVER CONFIGURATION --
77# Listen for incoming connections
88listen: false
9+# Listen on a specific address, supports IPv4 and IPv6
10+listenAddress:
11+ ipv4: 0.0.0.0
12+ ipv6: '[::]'
913# Enables IPv6 and/or IPv4 protocols. Need to have at least one enabled!
14+# - Use option "auto" to automatically detect support
15+# - Use true or false (no qoutes) to enable or disable each protocol
1016protocol:
1117 ipv4: true
1218 ipv6: false
@@ -77,6 +83,18 @@ cookieSecret: ''
7783disableCsrfProtection: false
7884# Disable startup security checks - NOT RECOMMENDED
7985securityOverride: 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
8098# -- ADVANCED CONFIGURATION --
8199# Open the browser automatically
82100autorun: true
@@ -179,6 +197,10 @@ ollama:
179197 # * 0: Unload the model immediately after the request
180198 # * N (any positive number): Keep the model loaded for N seconds after the request.
181199 keepAlive: -1
200+ # Controls the "num_batch" (batch size) parameter of the generation request
201+ # * -1: Use the default value of the model
202+ # * N (positive number): Use the specified value. Must be a power of 2, e.g. 128, 256, 512, etc.
203+ batchSize: -1
182204# -- ANTHROPIC CLAUDE API CONFIGURATION --
183205claude:
184206 # Enables caching of the system prompt (if supported).
@@ -198,3 +220,5 @@ claude:
198220 cachingAtDepth: -1
199221# -- SERVER PLUGIN CONFIGURATION --
200222enableServerPlugins: false
223+# Attempt to automatically update server plugins on startup
224+enableServerPluginsAutoUpdate: true
default/content/index.json+0 -4
@@ -672,10 +672,6 @@
672672 "type": "moving_ui"
673673 },
674674 {
675- "filename": "presets/moving-ui/Black Magic Time.json",
676- "type": "moving_ui"
677- },
678- {
679675 "filename": "presets/quick-replies/Default.json",
680676 "type": "quick_replies"
681677 },
default/content/presets/moving-ui/Black Magic Time.json+0 -45
@@ -1,45 +0,0 @@
1-{
2- "name": "Black Magic Time",
3- "movingUIState": {
4- "sheld": {
5- "top": 488,
6- "left": 1407,
7- "right": 1,
8- "bottom": 4,
9- "margin": "unset",
10- "width": 471,
11- "height": 439
12- },
13- "floatingPrompt": {
14- "width": 369,
15- "height": 441
16- },
17- "right-nav-panel": {
18- "top": 0,
19- "left": 1400,
20- "right": 111,
21- "bottom": 446,
22- "margin": "unset",
23- "width": 479,
24- "height": 487
25- },
26- "WorldInfo": {
27- "top": 41,
28- "left": 369,
29- "right": 642,
30- "bottom": 51,
31- "margin": "unset",
32- "width": 1034,
33- "height": 858
34- },
35- "left-nav-panel": {
36- "top": 442,
37- "left": 0,
38- "right": 1546,
39- "bottom": 25,
40- "margin": "unset",
41- "width": 368,
42- "height": 483
43- }
44- }
45-}
45 \ No newline at end of file
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+65 -11
@@ -1,12 +1,12 @@
11{
22 "name": "sillytavern",
33 "version": "1.12.1112",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "sillytavern",
99 "version": "1.12.1112",
1010 "hasInstallScript": true,
1111 "license": "AGPL-3.0",
1212 "dependencies": {
@@ -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",
@@ -41,7 +41,9 @@
4141 "html-entities": "^2.5.2",
4242 "iconv-lite": "^0.6.3",
4343 "ip-matching": "^2.1.2",
44+ "ip-regex": "^5.0.0",
4445 "ipaddr.js": "^2.0.1",
46+ "is-docker": "^3.0.0",
4547 "jimp": "^0.22.10",
4648 "localforage": "^1.10.0",
4749 "lodash": "^4.17.21",
@@ -1462,6 +1464,13 @@
14621464 "@types/jquery": "*"
14631465 }
14641466 },
1467+ "node_modules/@types/trusted-types": {
1468+ "version": "2.0.7",
1469+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
1470+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
1471+ "license": "MIT",
1472+ "optional": true
1473+ },
14651474 "node_modules/@types/write-file-atomic": {
14661475 "version": "4.0.3",
14671476 "resolved": "https://registry.npmjs.org/@types/write-file-atomic/-/write-file-atomic-4.0.3.tgz",
@@ -3217,10 +3226,13 @@
32173226 }
32183227 },
32193228 "node_modules/dompurify": {
32203229 "version": "3.12.74",
32213230 "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.12.74.tgz",
32223231 "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTaysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+eNpxSl5gmzn+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQMs/mg==",
32233232 "license": "(MPL-2.0 OR Apache-2.0)",
3233+ "optionalDependencies": {
3234+ "@types/trusted-types": "^2.0.7"
3235+ }
32243236 },
32253237 "node_modules/domutils": {
32263238 "version": "3.1.0",
@@ -4610,6 +4622,18 @@
46104622 "integrity": "sha512-/ok+VhKMasgR5gvTRViwRFQfc0qYt9Vdowg6TO4/pFlDCob5ZjGPkwuOoQVCd5OrMm20zqh+1vA8KLJZTeWudg==",
46114623 "license": "LGPL-3.0-only"
46124624 },
4625+ "node_modules/ip-regex": {
4626+ "version": "5.0.0",
4627+ "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
4628+ "integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==",
4629+ "license": "MIT",
4630+ "engines": {
4631+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
4632+ },
4633+ "funding": {
4634+ "url": "https://github.com/sponsors/sindresorhus"
4635+ }
4636+ },
46134637 "node_modules/ipaddr.js": {
46144638 "version": "2.1.0",
46154639 "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
@@ -4626,15 +4650,15 @@
46264650 "license": "MIT"
46274651 },
46284652 "node_modules/is-docker": {
46294653 "version": "23.20.10",
46304654 "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-23.20.10.tgz",
46314655 "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQeljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
46324656 "license": "MIT",
46334657 "bin": {
46344658 "is-docker": "cli.js"
46354659 },
46364660 "engines": {
4637- "node": ">=8"
4661+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
46384662 },
46394663 "funding": {
46404664 "url": "https://github.com/sponsors/sindresorhus"
@@ -4711,6 +4735,21 @@
47114735 "node": ">=8"
47124736 }
47134737 },
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+ },
47144753 "node_modules/isarray": {
47154754 "version": "1.0.0",
47164755 "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -5495,6 +5534,21 @@
54955534 "url": "https://github.com/sponsors/sindresorhus"
54965535 }
54975536 },
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+ },
54985552 "node_modules/openai": {
54995553 "version": "4.17.4",
55005554 "resolved": "https://registry.npmjs.org/openai/-/openai-4.17.4.tgz",
package.json+5 -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",
@@ -31,7 +31,9 @@
3131 "html-entities": "^2.5.2",
3232 "iconv-lite": "^0.6.3",
3333 "ip-matching": "^2.1.2",
34+ "ip-regex": "^5.0.0",
3435 "ipaddr.js": "^2.0.1",
36+ "is-docker": "^3.0.0",
3537 "jimp": "^0.22.10",
3638 "localforage": "^1.10.0",
3739 "lodash": "^4.17.21",
@@ -86,9 +88,10 @@
8688 "type": "git",
8789 "url": "https://github.com/SillyTavern/SillyTavern.git"
8890 },
8991 "version": "1.12.1112",
9092 "scripts": {
9193 "start": "node server.js",
94+ "debug": "node server.js --inspect",
9295 "start:deno": "deno run --allow-run --allow-net --allow-read --allow-write --allow-sys --allow-env server.js",
9396 "start:bun": "bun server.js",
9497 "start:no-csrf": "node server.js --disableCsrf",
plugins.js+8 -1
@@ -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));
@@ -48,6 +48,13 @@ async function updatePlugins() {
4848 console.log(`Updating plugin ${color.green(directory)}...`);
4949 const pluginPath = path.join(pluginsPath, directory);
5050 const pluginRepo = git(pluginPath);
51+
52+ const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
53+ if (!isRepo) {
54+ console.log(`Directory ${color.yellow(directory)} is not a Git repository`);
55+ continue;
56+ }
57+
5158 await pluginRepo.fetch();
5259 const commitHash = await pluginRepo.revparse(['HEAD']);
5360 const trackingBranch = await pluginRepo.revparse(['--abbrev-ref', '@{u}']);
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/css/mobile-styles.css+0 -2
@@ -216,8 +216,6 @@
216216
217217 }
218218
219- #showRawPrompt,
220- #copyPromptToClipboard,
221219 #groupCurrentMemberPopoutButton,
222220 #summaryExtensionPopoutButton {
223221 display: none;
public/css/popup.css+4 -0
@@ -72,6 +72,10 @@ dialog {
7272 overflow-x: auto;
7373}
7474
75+.popup.left_aligned_dialogue_popup .popup-content {
76+ text-align: start;
77+}
78+
7579/* Opening animation */
7680.popup[opening] {
7781 animation: pop-in var(--popup-animation-speed) ease-in-out;
public/css/select2-overrides.css+7 -0
@@ -100,6 +100,13 @@
100100 border: 1px solid var(--SmartThemeBorderColor);
101101}
102102
103+.select2-container .select2-results .select2-results__option--disabled {
104+ color: inherit;
105+ background-color: inherit;
106+ cursor: not-allowed;
107+ filter: brightness(0.5);
108+}
109+
103110.select2-container .select2-selection--multiple .select2-selection__choice,
104111.select2-container .select2-selection--single .select2-selection__choice {
105112 border-radius: 5px;
public/css/toggle-dependent.css+9 -0
@@ -473,6 +473,11 @@ label[for="trim_spaces"]:has(input:checked) i.warning {
473473 display: none;
474474}
475475
476+label[for="trim_spaces"]:not(:has(input:checked)) small {
477+ color: var(--warning);
478+ opacity: 1;
479+}
480+
476481#claude_function_prefill_warning {
477482 display: none;
478483 color: red;
@@ -489,3 +494,7 @@ label[for="trim_spaces"]:has(input:checked) i.warning {
489494#mistralai_other_models:empty {
490495 display: none;
491496}
497+
498+#banned_tokens_block_ooba:not(:has(#send_banned_tokens_textgenerationwebui:checked)) #banned_tokens_controls_ooba {
499+ filter: brightness(0.5);
500+}
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+167 -111
@@ -730,7 +730,7 @@
730730 <input type="range" id="top_k_openai" name="volume" min="0" max="500" step="1">
731731 </div>
732732 <div class="range-block-counter">
733733 <input type="number" min="0" max="200500" step="1" data-for="top_k_openai" id="top_k_counter_openai">
734734 </div>
735735 </div>
736736 </div>
@@ -1587,6 +1587,10 @@
15871587 <input type="checkbox" id="skip_special_tokens_textgenerationwebui" />
15881588 <small data-i18n="Skip Special Tokens">Skip Special Tokens</small>
15891589 </label>
1590+ <label data-tg-type="openrouter" class="checkbox_label flexGrow flexShrink" for="include_reasoning_textgenerationwebui">
1591+ <input type="checkbox" id="include_reasoning_textgenerationwebui" />
1592+ <small data-i18n="Request Model Reasoning">Request Model Reasoning</small>
1593+ </label>
15901594 <label data-tg-type="ooba, aphrodite, tabby" class="checkbox_label flexGrow flexShrink" for="temperature_last_textgenerationwebui">
15911595 <input type="checkbox" id="temperature_last_textgenerationwebui" />
15921596 <label>
@@ -1617,17 +1621,34 @@
16171621 </div>
16181622 <div data-tg-type-mode="except" data-tg-type="generic" id="banned_tokens_block_ooba" class="wide100p">
16191623 <hr class="width100p">
16201624 <h4div class="range-block-title justifyCentertitle_restorable">
1621- <span data-i18n="Banned Tokens">Banned Tokens/Strings</span>
1625+ <div>
1626+ <strong data-i18n="Banned Tokens">Banned Tokens/Strings</strong>
16221627 <div class="margin5 fa-solid fa-circle-info opacity50p " data-i18n="[title]LLaMA / Mistral / Yi models only" title="Enter sequences you don't want to appear in the output.&#13;Unquoted text will be tokenized in the back end and banned as tokens.&#13;[token ids] will be banned as-is.&#13;Most tokens have a leading space. Use token counter (with the correct tokenizer selected first!) if you are unsure.&#13;Enclose text in double quotes to ban the entire string as a set.&#13;Quoted Strings and [Token ids] must be on their own line."></div>
16231628 </h4div>
1629+ <label id="send_banned_tokens_label" for="send_banned_tokens_textgenerationwebui" class="checkbox_label">
1630+ <input id="send_banned_tokens_textgenerationwebui" type="checkbox" style="display:none;" />
1631+ <small><i class="fa-solid fa-power-off menu_button togglable margin0"></i></small>
1632+ </label>
1633+ </div>
1634+ <div id="banned_tokens_controls_ooba">
1635+ <div class="textAlignCenter">
1636+ <small data-i18n="Global list">Global list</small>
1637+ </div>
1638+ <div class="wide100p marginBot10">
1639+ <textarea id="global_banned_tokens_textgenerationwebui" class="text_pole textarea_compact" name="global_banned_tokens_textgenerationwebui" rows="3" data-i18n="[placeholder]Example: some text [42, 69, 1337]" placeholder='some text as tokens&#10;[420, 69, 1337]&#10;"Some verbatim string"'></textarea>
1640+ </div>
1641+ <div class="textAlignCenter">
1642+ <small data-i18n="Preset-specific list">Preset-specific list</small>
1643+ </div>
16241644 <div class="wide100p">
16251645 <textarea id="banned_tokens_textgenerationwebui" class="text_pole textarea_compact" name="banned_tokens_textgenerationwebui" rows="3" data-i18n="[placeholder]Example: some text [42, 69, 1337]" placeholder='some text as tokens&#10;[420, 69, 1337]&#10;"Some verbatim string"'></textarea>
16261646 </div>
16271647 </div>
1648+ </div>
16281649 <div class="range-block wide100p">
16291650 <div id="logit_bias_textgenerationwebui" class="range-block-title title_restorable">
16301651 <spanstrong data-i18n="Logit Bias">Logit Bias</spanstrong>
16311652 <div id="textgen_logit_bias_new_entry" class="menu_button menu_button_icon">
16321653 <i class="fa-xs fa-solid fa-plus"></i>
16331654 <small data-i18n="Add">Add</small>
@@ -1930,7 +1951,7 @@
19301951 </span>
19311952 </div>
19321953 </div>
19331954 <div class="range-block" data-source="openai,cohere,mistralai,custom,claude,openrouter,groq,deepseek">
19341955 <label for="openai_function_calling" class="checkbox_label flexWrap widthFreeExpand">
19351956 <input id="openai_function_calling" type="checkbox" />
19361957 <span data-i18n="Enable function calling">Enable function calling</span>
@@ -1953,6 +1974,7 @@
19531974 <span data-i18n="image_inlining_hint_3">menu to attach an image file to the chat.</span>
19541975 </div>
19551976 <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom">
1977+ <div class="flex-container oneline-dropdown">
19561978 <label for="openai_inline_image_quality" data-i18n="Inline Image Quality">
19571979 Inline Image Quality
19581980 </label>
@@ -1963,6 +1985,7 @@
19631985 </select>
19641986 </div>
19651987 </div>
1988+ </div>
19661989 <div class="range-block" data-source="makersuite">
19671990 <label for="use_makersuite_sysprompt" class="checkbox_label widthFreeExpand">
19681991 <input id="use_makersuite_sysprompt" type="checkbox" />
@@ -1977,20 +2000,32 @@
19772000 </span>
19782001 </div>
19792002 </div>
19802003 <div class="range-block" data-source="makersuite,deepseek,openrouter,custom">
19812004 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
19822005 <input id="openai_show_thoughts" type="checkbox" />
19832006 <span>
19842007 <span data-i18n="ShowRequest model reasoning">ShowRequest model reasoning</span>
19852008 <i class="opacity50p fa-solid fa-circle-info" title="Gemini 2.0 Thinking / DeepSeek Reasoner"></i>
19862009 </span>
19872010 </label>
19882011 <div class="toggle-description justifyLeft marginBot5">
19892012 <span data-i18n="DisplayAllows the model's internalto thoughtsreturn inits thethinking responseprocess.">
19902013 DisplayAllows the model's internalto thoughtsreturn inits thethinking responseprocess.
19912014 </span>
19922015 </div>
19932016 </div>
2017+ <div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom">
2018+ <div class="flex-container oneline-dropdown" title="Constrains effort on reasoning for reasoning models.&#10;Currently supported values are low, medium, and high.&#10;Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response." data-i18n="[title]Constrains effort on reasoning for reasoning models.">
2019+ <label for="openai_reasoning_effort" data-i18n="Reasoning Effort">
2020+ Reasoning Effort
2021+ </label>
2022+ <select id="openai_reasoning_effort">
2023+ <option data-i18n="openai_reasoning_effort_low" value="low">Low</option>
2024+ <option data-i18n="openai_reasoning_effort_medium" value="medium">Medium</option>
2025+ <option data-i18n="openai_reasoning_effort_high" value="high">High</option>
2026+ </select>
2027+ </div>
2028+ </div>
19942029 <div class="range-block" data-source="claude">
19952030 <div class="wide100p">
19962031 <div class="flex-container alignItemsCenter">
@@ -2805,27 +2840,6 @@
28052840 <div>
28062841 <h4 data-i18n="OpenAI Model">OpenAI Model</h4>
28072842 <select id="model_openai_select">
2808- <optgroup label="GPT-3.5 Turbo">
2809- <option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
2810- <option value="gpt-3.5-turbo-0125">gpt-3.5-turbo-0125 (2024)</option>
2811- <option value="gpt-3.5-turbo-1106">gpt-3.5-turbo-1106 (2023)</option>
2812- <option value="gpt-3.5-turbo-0613">gpt-3.5-turbo-0613 (2023)</option>
2813- <option value="gpt-3.5-turbo-0301">gpt-3.5-turbo-0301 (2023)</option>
2814- <option value="gpt-3.5-turbo-16k">gpt-3.5-turbo-16k</option>
2815- <option value="gpt-3.5-turbo-16k-0613">gpt-3.5-turbo-16k-0613 (2023)</option>
2816- </optgroup>
2817- <optgroup label="GPT-3.5 Turbo Instruct">
2818- <option value="gpt-3.5-turbo-instruct">gpt-3.5-turbo-instruct</option>
2819- <option value="gpt-3.5-turbo-instruct-0914">gpt-3.5-turbo-instruct-0914</option>
2820- </optgroup>
2821- <optgroup label="GPT-4">
2822- <option value="gpt-4">gpt-4</option>
2823- <option value="gpt-4-0613">gpt-4-0613 (2023)</option>
2824- <option value="gpt-4-0314">gpt-4-0314 (2023)</option>
2825- <option value="gpt-4-32k">gpt-4-32k</option>
2826- <option value="gpt-4-32k-0613">gpt-4-32k-0613 (2023)</option>
2827- <option value="gpt-4-32k-0314">gpt-4-32k-0314 (2023)</option>
2828- </optgroup>
28292843 <optgroup label="GPT-4o">
28302844 <option value="gpt-4o">gpt-4o</option>
28312845 <option value="gpt-4o-2024-11-20">gpt-4o-2024-11-20</option>
@@ -2833,29 +2847,44 @@
28332847 <option value="gpt-4o-2024-05-13">gpt-4o-2024-05-13</option>
28342848 <option value="chatgpt-4o-latest">chatgpt-4o-latest</option>
28352849 </optgroup>
28362850 <optgroup label="gptGPT-4o- mini">
28372851 <option value="gpt-4o-mini">gpt-4o-mini</option>
28382852 <option value="gpt-4o-mini-2024-0711-1820">gpt-4o-mini-2024-0711-1820</option>
2853+ <option value="gpt-4o-2024-08-06">gpt-4o-2024-08-06</option>
2854+ <option value="gpt-4o-2024-05-13">gpt-4o-2024-05-13</option>
2855+ <option value="chatgpt-4o-latest">chatgpt-4o-latest</option>
28392856 </optgroup>
28402857 <optgroup label="GPT-4o1 Turboand o1-mini">
2858+ <option value="o1">o1</option>
2859+ <option value="o1-2024-12-17">o1-2024-12-17</option>
2860+ <option value="o1-mini">o1-mini</option>
2861+ <option value="o1-mini-2024-09-12">o1-mini-2024-09-12</option>
2862+ <option value="o1-preview">o1-preview</option>
2863+ <option value="o1-preview-2024-09-12">o1-preview-2024-09-12</option>
2864+ </optgroup>
2865+ <optgroup label="o3">
2866+ <option value="o3-mini">o3-mini</option>
2867+ <option value="o3-mini-2025-01-31">o3-mini-2025-01-31</option>
2868+ </optgroup>
2869+ <optgroup label="GPT-4 Turbo and GPT-4">
28412870 <option value="gpt-4-turbo">gpt-4-turbo</option>
28422871 <option value="gpt-4-turbo-2024-04-09">gpt-4-turbo-2024-04-09</option>
28432872 <option value="gpt-4-turbo-preview">gpt-4-turbo-preview</option>
2844- <option value="gpt-4-vision-preview">gpt-4-vision-preview</option>
28452873 <option value="gpt-4-0125-preview">gpt-4-0125-preview (2024)</option>
28462874 <option value="gpt-4-1106-preview">gpt-4-1106-preview (2023)</option>
2875+ <option value="gpt-4">gpt-4</option>
2876+ <option value="gpt-4-0613">gpt-4-0613 (2023)</option>
2877+ <option value="gpt-4-0314">gpt-4-0314 (2023)</option>
28472878 </optgroup>
28482879 <optgroup label="o1GPT-3.5 Turbo">
28492880 <option value="o1gpt-preview3.5-turbo">o1gpt-preview3.5-turbo</option>
28502881 <option value="o1gpt-mini3.5-turbo-0125">o1gpt-mini3.5-turbo-0125 (2024)</option>
2882+ <option value="gpt-3.5-turbo-1106">gpt-3.5-turbo-1106 (2023)</option>
2883+ <option value="gpt-3.5-turbo-instruct">gpt-3.5-turbo-instruct</option>
28512884 </optgroup>
28522885 <optgroup label="Other">
28532886 <option value="text-davincibabbage-003002">text-davincibabbage-003002</option>
28542887 <option value="text-davinci-002">text-davinci-002</option>
2855- <option value="text-curie-001">text-curie-001</option>
2856- <option value="text-babbage-001">text-babbage-001</option>
2857- <option value="text-ada-001">text-ada-001</option>
2858- <option value="code-davinci-002">code-davinci-002</option>
28592888 </optgroup>
28602889 <optgroup id="openai_external_category" label="External">
28612890 </optgroup>
@@ -3054,6 +3083,7 @@
30543083 <h4 data-i18n="Google Model">Google Model</h4>
30553084 <select id="model_google_select">
30563085 <optgroup label="Primary">
3086+ <option value="gemini-2.0-flash">Gemini 2.0 Flash</option>
30573087 <option value="gemini-1.5-pro">Gemini 1.5 Pro</option>
30583088 <option value="gemini-1.5-flash">Gemini 1.5 Flash</option>
30593089 <option value="gemini-1.0-pro">Gemini 1.0 Pro (Deprecated)</option>
@@ -3062,6 +3092,11 @@
30623092 <option value="gemini-1.0-ultra-latest">Gemini 1.0 Ultra</option>
30633093 </optgroup>
30643094 <optgroup label="Subversions">
3095+ <option value="gemini-2.0-pro-exp">Gemini 2.0 Pro Experimental</option>
3096+ <option value="gemini-2.0-pro-exp-02-05">Gemini 2.0 Pro Experimental 2025-02-05</option>
3097+ <option value="gemini-2.0-flash-lite-preview">Gemini 2.0 Flash-Lite Preview</option>
3098+ <option value="gemini-2.0-flash-lite-preview-02-05">Gemini 2.0 Flash-Lite Preview 2025-02-05</option>
3099+ <option value="gemini-2.0-flash-001">Gemini 2.0 Flash [001]</option>
30653100 <option value="gemini-2.0-flash-thinking-exp">Gemini 2.0 Flash Thinking Experimental</option>
30663101 <option value="gemini-2.0-flash-thinking-exp-01-21">Gemini 2.0 Flash Thinking Experimental 2025-01-21</option>
30673102 <option value="gemini-2.0-flash-thinking-exp-1219">Gemini 2.0 Flash Thinking Experimental 2024-12-19</option>
@@ -3151,33 +3186,33 @@
31513186 </div>
31523187 <h4 data-i18n="Groq Model">Groq Model</h4>
31533188 <select id="model_groq_select">
31543189 <optgroup label="LlamaAlibaba 3.3Cloud">
31553190 <option value="llamaqwen-32.3-70b5-versatile32b">llamaqwen-32.3-70b5-versatile32b</option>
3191+ <option value="qwen-2.5-coder-32b">qwen-2.5-coder-32b</option>
3192+ </optgroup>
3193+ <optgroup label="DeepSeek / Alibaba Cloud">
3194+ <option value="deepseek-r1-distill-qwen-32b">deepseek-r1-distill-qwen-32b</option>
3195+ </optgroup>
3196+ <optgroup label="DeepSeek / Meta">
3197+ <option value="deepseek-r1-distill-llama-70b">deepseek-r1-distill-llama-70b</option>
3198+ </optgroup>
3199+ <optgroup label="Google">
3200+ <option value="gemma2-9b-it">gemma2-9b-it</option>
31563201 </optgroup>
31573202 <optgroup label="Llama 3.2Meta">
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>
31583205 <option value="llama-3.2-1b-preview">llama-3.2-1b-preview </option>
31593206 <option value="llama-3.2-3b-preview">llama-3.2-3b-preview </option>
3160- <option value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview</option>
31613207 <option value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview </option>
3162- </optgroup>
3208+ <option value="llama-3.3-70b-specdec">llama-3.3-70b-specdec </option>
3163- <optgroup label="Llama 3.1">
3209+ <option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile </option>
31643210 <option value="llama-guard-3.1-8b-instant">llama-guard-3.1-8b-instant </option>
3165- <option value="llama-3.1-70b-versatile">llama-3.1-70b-versatile</option>
3166- <option value="llama-3.1-405b-reasoning">llama-3.1-405b-reasoning</option>
3167- </optgroup>
3168- <optgroup label="Llama 3">
3169- <option value="llama3-groq-8b-8192-tool-use-preview">llama3-groq-8b-8192-tool-use-preview</option>
3170- <option value="llama3-groq-70b-8192-tool-use-preview">llama3-groq-70b-8192-tool-use-preview</option>
3171- <option value="llama3-8b-8192">llama3-8b-8192</option>
31723211 <option value="llama3-70b-8192">llama3-70b-8192 </option>
3212+ <option value="llama3-8b-8192">llama3-8b-8192 </option>
31733213 </optgroup>
31743214 <optgroup label="GemmaMistral AI">
3175- <option value="gemma-7b-it">gemma-7b-it</option>
3176- <option value="gemma2-9b-it">gemma2-9b-it</option>
3177- </optgroup>
3178- <optgroup label="Other">
31793215 <option value="mixtral-8x7b-32768">mixtral-8x7b-32768</option>
3180- <option value="llava-v1.5-7b-4096-preview">llava-v1.5-7b-4096-preview</option>
31813216 </optgroup>
31823217 </select>
31833218 </div>
@@ -3227,32 +3262,23 @@
32273262 <h4 data-i18n="Perplexity Model">Perplexity Model</h4>
32283263 <select id="model_perplexity_select">
32293264 <optgroup label="Perplexity Sonar Models">
3265+ <option value="sonar">sonar</option>
3266+ <option value="sonar-pro">sonar-pro</option>
3267+ <option value="sonar-reasoning">sonar-reasoning</option>
3268+ <option value="sonar-reasoning-pro">sonar-reasoning-pro</option>
3269+ </optgroup>
3270+ <optgroup label="Offline Models">
3271+ <option value="r1-1776">r1-1776</option>
3272+ </optgroup>
3273+ <optgroup label="Deprecated Models">
3274+ <!-- These are scheduled for deprecation after 2/22/2025 -->
32303275 <option value="llama-3.1-sonar-small-128k-online">llama-3.1-sonar-small-128k-online</option>
32313276 <option value="llama-3.1-sonar-large-128k-online">llama-3.1-sonar-large-128k-online</option>
32323277 <option value="llama-3.1-sonar-huge-128k-online">llama-3.1-sonar-huge-128k-online</option>
3233- </optgroup>
3278+ <!-- These are not listed on the site anymore -->
3234- <optgroup label="Perplexity Chat Models">
32353279 <option value="llama-3.1-sonar-small-128k-chat">llama-3.1-sonar-small-128k-chat</option>
32363280 <option value="llama-3.1-sonar-large-128k-chat">llama-3.1-sonar-large-128k-chat</option>
32373281 </optgroup>
3238- <optgroup label="Open-Source Models">
3239- <option value="llama-3.1-8b-instruct">llama-3.1-8b-instruct</option>
3240- <option value="llama-3.1-70b-instruct">llama-3.1-70b-instruct</option>
3241- </optgroup>
3242- <optgroup label="Deprecated Models">
3243- <option value="llama-3-sonar-small-32k-chat">llama-3-sonar-small-32k-chat</option>
3244- <option value="llama-3-sonar-small-32k-online">llama-3-sonar-small-32k-online</option>
3245- <option value="llama-3-sonar-large-32k-chat">llama-3-sonar-large-32k-chat</option>
3246- <option value="llama-3-sonar-large-32k-online">llama-3-sonar-large-32k-online</option>
3247- <option value="sonar-small-chat">sonar-small-chat</option>
3248- <option value="sonar-small-online">sonar-small-online</option>
3249- <option value="sonar-medium-chat">sonar-medium-chat</option>
3250- <option value="sonar-medium-online">sonar-medium-online</option>
3251- <option value="llama-3-8b-instruct">llama-3-8b-instruct</option>
3252- <option value="llama-3-70b-instruct">llama-3-70b-instruct</option>
3253- <option value="mistral-7b-instruct">mistral-7b-instruct (v0.2)</option>
3254- <option value="mixtral-8x7b-instruct">mixtral-8x7b-instruct</option>
3255- </optgroup>
32563282 </select>
32573283 </div>
32583284 <form id="cohere_form" data-source="cohere" action="javascript:void(null);" method="post" enctype="multipart/form-data">
@@ -3524,7 +3550,7 @@
35243550 </label>
35253551 <label id="instruct_enabled_label"for="instruct_enabled" class="checkbox_label flex1" title="Enable Instruct Mode" data-i18n="[title]instruct_enabled">
35263552 <input id="instruct_enabled" type="checkbox" style="display:none;" />
35273553 <small><i class="fa-solid fa-power-off menu_button togglable margin0"></i></small>
35283554 </label>
35293555 </div>
35303556 </h4>
@@ -3702,7 +3728,7 @@
37023728 <div class="flex-container">
37033729 <label id="sysprompt_enabled_label" for="sysprompt_enabled" class="checkbox_label flex1" title="Enable System Prompt" data-i18n="[title]sysprompt_enabled">
37043730 <input id="sysprompt_enabled" type="checkbox" style="display:none;" />
37053731 <small><i class="fa-solid fa-power-off menu_button togglable margin0"></i></small>
37063732 </label>
37073733 </div>
37083734 </h4>
@@ -3756,8 +3782,8 @@
37563782 </div>
37573783 <label class="checkbox_label" for="custom_stopping_strings_macro">
37583784 <input id="custom_stopping_strings_macro" type="checkbox" checked>
37593785 <small data-i18n="Replace Macro in Custom StoppingStop Strings">
37603786 Replace Macro in Custom StoppingStop Strings
37613787 </small>
37623788 </label>
37633789 </div>
@@ -3804,12 +3830,42 @@
38043830 <span data-i18n="Reasoning">Reasoning</span>
38053831 </h4>
38063832 <div>
3807- <label class="checkbox_label" for="reasoning_add_to_prompts" title="Add existing reasoning blocks to prompts. To add a new reasoning block, use the message edit menu." data-i18n="[title]reasoning_add_to_prompts">
3833+ <div class="flex-container alignItemsBaseline">
3834+ <label class="checkbox_label flex1" for="reasoning_auto_parse" title="Automatically parse reasoning blocks from main content between the reasoning prefix/suffix. Both fields must be defined and non-empty." data-i18n="[title]reasoning_auto_parse">
3835+ <input id="reasoning_auto_parse" type="checkbox" />
3836+ <small data-i18n="Auto-Parse">
3837+ Auto-Parse
3838+ </small>
3839+ </label>
3840+ <label class="checkbox_label flex1" for="reasoning_auto_expand" title="Automatically expand reasoning blocks." data-i18n="[title]reasoning_auto_expand">
3841+ <input id="reasoning_auto_expand" type="checkbox" />
3842+ <small data-i18n="Auto-Expand">
3843+ Auto-Expand
3844+ </small>
3845+ </label>
3846+ <label class="checkbox_label flex1" for="reasoning_show_hidden" title="Show reasoning time for models with hidden reasoning." data-i18n="[title]reasoning_show_hidden">
3847+ <input id="reasoning_show_hidden" type="checkbox" />
3848+ <small data-i18n="Show Hidden">
3849+ Show Hidden
3850+ </small>
3851+ </label>
3852+ </div>
3853+ <div class="flex-container alignItemsBaseline">
3854+ <label class="checkbox_label flex1" for="reasoning_add_to_prompts" title="Add existing reasoning blocks to prompts. To add a new reasoning block, use the message edit menu." data-i18n="[title]reasoning_add_to_prompts">
38083855 <input id="reasoning_add_to_prompts" type="checkbox" />
38093856 <small data-i18n="Add Reasoning to Prompts">
38103857 Add Reasoning to Prompts
38113858 </small>
38123859 </label>
3860+ <div class="flex1 flex-container alignItemsBaseline" title="Maximum number of reasoning blocks to be added per prompt, counting from the last message." data-i18n="[title]reasoning_max_additions">
3861+ <input id="reasoning_max_additions" class="text_pole textarea_compact widthUnset" type="number" min="0" max="999"></textarea>
3862+ <small data-i18n="Max">Max</small>
3863+ </div>
3864+ </div>
3865+ <details>
3866+ <summary data-i18n="Reasoning Formatting">
3867+ Reasoning Formatting
3868+ </summary>
38133869 <div class="flex-container">
38143870 <div class="flex1" title="Inserted before the reasoning content." data-i18n="[title]reasoning_prefix">
38153871 <small data-i18n="Prefix">Prefix</small>
@@ -3825,11 +3881,8 @@
38253881 <small data-i18n="Separator">Separator</small>
38263882 <textarea id="reasoning_separator" class="text_pole textarea_compact autoSetHeight"></textarea>
38273883 </div>
3828- <div class="flex1" title="Maximum number of reasoning blocks to be added per prompt, counting from the last message." data-i18n="[title]reasoning_max_additions">
3829- <small data-i18n="Max Additions">Max Additions</small>
3830- <input id="reasoning_max_additions" class="text_pole textarea_compact" type="number" min="0" max="999"></textarea>
3831- </div>
38323884 </div>
3885+ </details>
38333886 </div>
38343887 </div>
38353888 <div>
@@ -3964,7 +4017,7 @@
39644017 <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">
39654018 <small>
39664019 <span data-i18n="Max Recursion Steps">Max Recursion Steps</span>
39674020 <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>
39684021 </small>
39694022 <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">
39704023 <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">
@@ -4629,7 +4682,7 @@
46294682 <small data-i18n="Enabled">Enabled</small>
46304683 </label>
46314684 <small data-i18n="Minimum generated message length">Minimum generated message length</small>
46324685 <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">
46334686 <small data-i18n="Blacklisted words">Blacklisted words</small>
46344687 <div class="auto_swipe">
46354688 <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>
@@ -4845,6 +4898,7 @@
48454898 </div>
48464899 <div id="extensions_settings" class="flex1 wide50p">
48474900 <div id="assets_container" class="extension_container"></div>
4901+ <div id="typing_indicator_container" class="extension_container"></div>
48484902 <div id="expressions_container" class="extension_container"></div>
48494903 <div id="sd_container" class="extension_container"></div>
48504904 <div id="tts_container" class="extension_container"></div>
@@ -5880,7 +5934,7 @@
58805934 <div class="inline-drawer-content flex-container paddingBottom5px wide100p">
58815935 <div class="flex-container wide100p alignitemscenter">
58825936 <div name="keywordsAndLogicBlock" class="flex-container wide100p alignitemscenter">
58835937 <div class="world_entry_form_control keyprimary flex1">
58845938 <small class="displayNone">
58855939 <span data-i18n="Comma separated (required)">
58865940 Comma separated (required)
@@ -6302,14 +6356,19 @@
63026356 </div>
63036357 </div>
63046358 <details class="mes_reasoning_details">
63056359 <summary class="mes_reasoning_summary flex-container">
6306- <span data-i18n="Reasoning">Reasoning</span>
6360+ <div class="mes_reasoning_header_block flex-container">
63076361 <div class="mes_reasoning_actionsmes_reasoning_header flex-container">
6308- <div class="mes_reasoning_edit_done mes_button fa-solid fa-check" title="Confirm" data-i18n="[title]Confirmedit"></div>
6362+ <span class="mes_reasoning_header_title" data-i18n="Thought for some time">Thought for some time</span>
63096363 <div class="mes_reasoning_edit_cancel mes_buttonmes_reasoning_arrow fa-solid fa-xmark" title="Cancel edit" datachevron-i18n="[title]Cancel editup"></div>
6310- <div class="mes_reasoning_edit mes_button fa-solid fa-pencil" title="Edit reasoning" data-i18n="[title]Edit reasoning"></div>
6364+ </div>
6365+ </div>
6366+ <div class="mes_reasoning_actions flex-container">
6367+ <div class="mes_reasoning_edit_done menu_button edit_button fa-solid fa-check" title="Confirm" data-i18n="[title]Confirmedit"></div>
6368+ <div class="mes_reasoning_delete menu_button edit_button fa-solid fa-trash-can" title="Remove reasoning" data-i18n="[title]Remove reasoning"></div>
6369+ <div class="mes_reasoning_edit_cancel menu_button edit_button fa-solid fa-xmark" title="Cancel edit" data-i18n="[title]Cancel edit"></div>
63116370 <div class="mes_reasoning_copy mes_button fa-solid fa-copy" title="Copy reasoning" data-i18n="[title]Copy reasoning"></div>
63126371 <div class="mes_reasoning_deletemes_reasoning_edit mes_button fa-solid fa-trash-canpencil" title="RemoveEdit reasoning" data-i18n="[title]RemoveEdit reasoning"></div>
63136372 </div>
63146373 </summary>
63156374 <div class="mes_reasoning"></div>
@@ -6528,9 +6587,6 @@
65286587 </div>
65296588
65306589 <!-- chat and input bar -->
6531- <div id="typing_indicator_template" class="template_element">
6532- <div class="typing_indicator"><span class="typing_indicator_name">CHAR</span> is typing</div>
6533- </div>
65346590 <div id="message_file_template" class="template_element">
65356591 <div class="mes_file_container">
65366592 <div class="fa-lg fa-solid fa-file-alt mes_file_icon"></div>
@@ -6890,8 +6946,8 @@
68906946 </div>
68916947 <div id="form_sheld">
68926948 <div id="dialogue_del_mes">
68936949 <div id="dialogue_del_mes_ok" data-i18n="Delete" class="menu_button">Delete</div>
68946950 <div id="dialogue_del_mes_cancel" data-i18n="Cancel" class="menu_button">Cancel</div>
68956951 </div>
68966952 <div id="send_form" class="no-connection">
68976953 <form id="file_form" class="wide100p displayNone">
public/lib/eventemitter.js+42 -1
@@ -24,10 +24,22 @@ if (typeof Array.prototype.indexOf === 'function') {
2424
2525
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,6 +181,10 @@ 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) {
public/locales/ar-sa.json+2 -2
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "فصل بفواصل دون مسافة بينها",
483483 "Custom Stopping Strings": "سلاسل توقف مخصصة",
484484 "JSON serialized array of strings": "مصفوفة سلسلة JSON متسلسلة",
485485 "Replace Macro in Custom StoppingStop Strings": "استبدال الماكرو في سلاسل التوقف المخصصة",
486486 "Auto-Continue": "المتابعة التلقائية",
487487 "Allow for Chat Completion APIs": "السماح بواجهات برمجة التطبيقات لإكمال الدردشة",
488488 "Target length (tokens)": "الطول المستهدف (رموز)",
@@ -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
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "getrennt durch Kommas ohne Leerzeichen dazwischen",
483483 "Custom Stopping Strings": "Benutzerdefinierte Stoppzeichenfolgen",
484484 "JSON serialized array of strings": "JSON serialisierte Reihe von Zeichenfolgen",
485485 "Replace Macro in Custom StoppingStop Strings": "Makro in benutzerdefinierten Stoppzeichenfolgen ersetzen",
486486 "Auto-Continue": "Automatisch fortsetzen",
487487 "Allow for Chat Completion APIs": "Erlaube Chat-Vervollständigungs-APIs",
488488 "Target length (tokens)": "Ziel-Länge (Tokens)",
@@ -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
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "separe con comas sin espacio entre ellas",
483483 "Custom Stopping Strings": "Cadenas de Detención Personalizadas",
484484 "JSON serialized array of strings": "Arreglo de cadenas serializado en JSON",
485485 "Replace Macro in Custom StoppingStop Strings": "Reemplazar macro en Cadenas de Detención Personalizadas",
486486 "Auto-Continue": "Autocontinuar",
487487 "Allow for Chat Completion APIs": "Permitir para APIs de Completado de Chat",
488488 "Target length (tokens)": "Longitud objetivo (tokens)",
@@ -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+5 -6
@@ -434,7 +434,7 @@
434434 "Non-markdown strings": "Chaînes non Markdown",
435435 "Custom Stopping Strings": "Chaînes d'arrêt personnalisées",
436436 "JSON serialized array of strings": "Tableau de chaînes sérialisé JSON",
437437 "Replace Macro in Custom StoppingStop Strings": "Remplacer les macro dans les chaînes d'arrêt personnalisées",
438438 "Auto-Continue": "Auto-Continue",
439439 "Allow for Chat Completion APIs": "Autoriser les APIs de complétion de chat",
440440 "Target length (tokens)": "Longueur cible (tokens)",
@@ -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",
@@ -1385,8 +1385,8 @@
13851385 "enable_functions_desc_1": "Autorise l'utilisation",
13861386 "enable_functions_desc_2": "outils de fonction",
13871387 "enable_functions_desc_3": "Peut être utilisé par diverses extensions pour fournir des fonctionnalités supplémentaires.",
13881388 "ShowRequest model reasoning": "AfficherDemander les pensées du modèle",
13891389 "DisplayAllows the model's internalto thoughtsreturn inits thethinking responseprocess.": "AfficherPermet lesau penséesmodèle internesde duretourner modèleson dansprocessus lade réponseréflexion.",
13901390 "Confirm token parsing with": "Confirmer l'analyse des tokens avec",
13911391 "openai_logit_bias_no_items": "Aucun élément",
13921392 "api_no_connection": "Pas de connection...",
@@ -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
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "aðskilið með kommum án bila milli",
483483 "Custom Stopping Strings": "Eigin stopp-strengir",
484484 "JSON serialized array of strings": "JSON raðað fylki af strengjum",
485485 "Replace Macro in Custom StoppingStop Strings": "Skiptu út í macro í sérsniðnum stoppa strengjum",
486486 "Auto-Continue": "Sjálfvirk Forná",
487487 "Allow for Chat Completion APIs": "Leyfa fyrir spjall Loka APIs",
488488 "Target length (tokens)": "Markaðarlengd (texti)",
@@ -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
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "separati con virgole senza spazio tra loro",
483483 "Custom Stopping Strings": "Stringhe di Stop Personalizzate",
484484 "JSON serialized array of strings": "Matrice serializzata JSON di stringhe",
485485 "Replace Macro in Custom StoppingStop Strings": "Sostituisci Macro in Stringhe di Arresto Personalizzate",
486486 "Auto-Continue": "Auto-continua",
487487 "Allow for Chat Completion APIs": "Consenti per API di completamento chat",
488488 "Target length (tokens)": "Lunghezza obiettivo (token)",
@@ -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
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "間にスペースのないカンマで区切ります",
483483 "Custom Stopping Strings": "カスタム停止文字列",
484484 "JSON serialized array of strings": "文字列のJSONシリアル化配列",
485485 "Replace Macro in Custom StoppingStop Strings": "カスタム停止文字列内のマクロを置換する",
486486 "Auto-Continue": "自動継続",
487487 "Allow for Chat Completion APIs": "チャット補完APIを許可",
488488 "Target length (tokens)": "ターゲット長さ(トークン)",
@@ -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+11 -12
@@ -211,7 +211,7 @@
211211 "Sampler Priority": "샘플러 우선 순위",
212212 "Ooba only. Determines the order of samplers.": "Ooba 전용. 샘플러의 순서를 결정합니다.",
213213 "Character Names Behavior": "캐릭터 이름 동작",
214214 "[title]character_names_none": "캐릭터 이름 접두사를 추가하지 않습니다. 그룹 채팅에서는 좋지 않을 수 있으므로, 이 설정을 선택할 때는 주의해야 합니다.",
215215 "Helps the model to associate messages with characters.": "모델이 메시지를 캐릭터와 연관시키는 데 도움이 됩니다.",
216216 "None": "없음",
217217 "None (not injected)": "없음 (삽입되지 않음)",
@@ -404,7 +404,7 @@
404404 "Custom API Key": "커스텀 API 키",
405405 "Available Models": "사용 가능한 모델",
406406 "Prompt Post-Processing": "신속한 후처리",
407407 "[title]API Connections;[no_connection_text]api_no_connection": "연결이 되지 않았습니다...",
408408 "Applies additional processing to the prompt before sending it to the API.": "API로 보내기 전에 프롬프트에 추가 처리를 적용합니다.",
409409 "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "짧은 테스트 메시지를 보내어 API 연결을 확인합니다. 이에 대해 유료 크레딧이 지불될 수 있음을 인식하세요!",
410410 "Test Message": "테스트 메시지",
@@ -492,7 +492,7 @@
492492 "separate with commas w/o space between": "쉼표로 구분 (공백 없이)",
493493 "Custom Stopping Strings": "사용자 정의 중지 문자열",
494494 "JSON serialized array of strings": "문자열의 JSON 직렬화된 배열",
495495 "Replace Macro in Custom StoppingStop Strings": "사용자 정의 중단 문자열에서 매크로 교체",
496496 "Auto-Continue": "자동 계속하기",
497497 "Allow for Chat Completion APIs": "채팅 완성 API 허용",
498498 "Target length (tokens)": "대상 길이 (토큰)",
@@ -625,7 +625,7 @@
625625 "Single-row message input area. Mobile only, no effect on PC": "한 줄짜리 메시지 입력 영역. 모바일 전용, PC에는 영향 없음",
626626 "Compact Input Area (Mobile)": "조그마한 입력 영역 (모바일)",
627627 "Swipe # for All Messages": "모든 스와이프 메시지에 대해 번호 매기기",
628628 "[title]Display swipe numbers for all messages, not just the last.": "마지막 메시지만이 아니라 모든 메시지에 대한 스와이프 번호를 표시합니다.",
629629 "In the Character Management panel, show quick selection buttons for favorited characters": "캐릭터 관리 패널에서 즐겨찾는 캐릭터에 대한 빠른 선택 버튼을 표시합니다",
630630 "Characters Hotswap": "캐릭터 핫스왑",
631631 "Enable magnification for zoomed avatar display.": "마우스 포인터를 아바타 위에 올려두면 아바타가 확대 됩니다.",
@@ -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": "로컬",
@@ -1538,7 +1537,7 @@
15381537 "Only apply color as accent": "색상은 오직 강조로써만 적용됩니다",
15391538 "qr--colorClear": "색상 지우기",
15401539 "Color": "색상",
15411540 "[title]world_button_title": "캐릭터 로어. 클릭하여 로드하세요. Shift를 클릭하면 '월드 인포 링크' 팝업이 열립니다.",
15421541 "Select TTS Provider": "TTS 공급자 선택",
15431542 "tts_enabled": "활성화",
15441543 "Narrate user messages": "사용자 메시지 나레이션",
@@ -1583,15 +1582,15 @@
15831582 "Prompt Content": "프롬프트 내용",
15841583 "Instruct Sequences": "지시 시퀀스",
15851584 "Prefer Character Card Instructions": "캐릭터 카드의 지시사항을 선호",
15861585 "[title]If checked and the character card contains a Post-History Instructions override, use that instead": "활성화 된 경우, 캐릭터 카드에 Post-History 지시 무시 항목이 포함되어 있으면, 카드 지시사항의 내용으로 대신 사용합니다.",
15871586 "Auto-select Input Text": "입력 텍스트 자동 선택",
15881587 "[title]Enable auto-select of input text in some text fields when clicking/selecting them. Applies to popup input textboxes, and possible other custom input fields.": "일부 텍스트 필드를 클릭하거나 선택할 때 자동으로 입력된 텍스트가 선택되도록 설정합니다. 팝업 입력창과 기타 커스텀 입력 필드에 적용됩니다.",
15891588 "Markdown Hotkeys": "마크다운 입력 단축키",
15901589 "[title]markdown_hotkeys_desc": "특정 텍스트 입력창에서 마크다운 형식 문자를 입력하기 위한 단축키를 활성화합니다. '/help hotkeys'를 참고하세요.",
15911590 "Show group chat queue": "그룹 채팅 대기열 표시",
15921591 "[title]In group chat, highlight the character(s) that are currently queued to generate responses and the order in which they will respond.": "그룹 채팅에서 응답을 생성하기 위해 현재 대기 중인 캐릭터와 응답할 순서를 강조 표시합니다.",
15931592 "Quick 'Impersonate' button": "빠른 '사칭' 버튼",
15941593 "[title]Show a button in the input area to ask the AI to impersonate your character for a single message": "입력 영역에 AI에게 한 메시지 동안 당신의 캐릭터 연기를 사칭하도록 요청하는 버튼을 표시합니다.",
15951594 "Injection Template": "삽입 템플릿",
15961595 "Query messages": "쿼리 메시지 수",
15971596 "Score threshold": "점수 임계값",
public/locales/nl-nl.json+2 -2
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "gescheiden met komma's zonder spatie ertussen",
483483 "Custom Stopping Strings": "Aangepaste Stopreeksen",
484484 "JSON serialized array of strings": "JSON geserialiseerde reeks van strings",
485485 "Replace Macro in Custom StoppingStop Strings": "Macro vervangen in aangepaste stopreeksen",
486486 "Auto-Continue": "Automatisch doorgaan",
487487 "Allow for Chat Completion APIs": "Chatvervolledigings-API's toestaan",
488488 "Target length (tokens)": "Doellengte (tokens)",
@@ -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
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "separe com vírgulas sem espaço entre",
483483 "Custom Stopping Strings": "Cadeias de parada personalizadas",
484484 "JSON serialized array of strings": "Matriz de strings serializada em JSON",
485485 "Replace Macro in Custom StoppingStop Strings": "Substituir Macro em Strings de Parada Personalizadas",
486486 "Auto-Continue": "Auto-Continuar",
487487 "Allow for Chat Completion APIs": "Permitir APIs de Completar Chat",
488488 "Target length (tokens)": "Comprimento alvo (tokens)",
@@ -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
@@ -161,7 +161,7 @@
161161 "View hidden API keys": "Посмотреть скрытые API-ключи",
162162 "Advanced Formatting": "Расширенное форматирование",
163163 "Context Template": "Шаблон контекста",
164164 "Replace Macro in Custom StoppingStop Strings": "Заменять макросы в пользовательских стоп-строках",
165165 "Story String": "Строка истории",
166166 "Example Separator": "Разделитель примеров сообщений",
167167 "Chat Start": "Начало чата",
@@ -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": "Токенов",
@@ -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
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "розділяйте комами без пропусків між ними",
483483 "Custom Stopping Strings": "Власні рядки зупинки",
484484 "JSON serialized array of strings": "JSON-серіалізований масив рядків",
485485 "Replace Macro in Custom StoppingStop Strings": "Замінювати макроси у власних рядках зупинки",
486486 "Auto-Continue": "Автоматичне продовження",
487487 "Allow for Chat Completion APIs": "Дозволити для Chat Completion API",
488488 "Target length (tokens)": "Цільова довжина (токени)",
@@ -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
@@ -482,7 +482,7 @@
482482 "separate with commas w/o space between": "phân tách bằng dấu phẩy không có khoảng trắng giữa",
483483 "Custom Stopping Strings": "Chuỗi dừng tùy chỉnh",
484484 "JSON serialized array of strings": "Mảng chuỗi được tuần tự hóa JSON",
485485 "Replace Macro in Custom StoppingStop Strings": "Thay thế Macro trong Chuỗi Dừng Tùy chỉnh",
486486 "Auto-Continue": "Tự động Tiếp tục",
487487 "Allow for Chat Completion APIs": "Cho phép các API hoàn thành Trò chuyện",
488488 "Target length (tokens)": "Độ dài mục tiêu (token)",
@@ -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+15 -16
@@ -215,7 +215,7 @@
215215 "Classifier Free Guidance. More helpful tip coming soon": "无分类器指导(CFG)。更多有用的提示敬请期待。",
216216 "Scale": "缩放比例",
217217 "Negative Prompt": "负面提示词",
218218 "Used if CFG Scale is unset globally, per chat or character": "如果无分类器指导(CFG)缩放比例未在全局设置如果CFG缩放比例未被全局设置它将作用于每个聊天或每个角色它将作用于所有聊天或角色",
219219 "Add text here that would make the AI generate things you don't want in your outputs.": "请在此处添加文本,以避免生成您不希望出现在输出中的内容。",
220220 "Grammar String": "语法字符串",
221221 "GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF 或 EBNF,取决于使用的后端。如果您使用这个,您应该知道该用哪一个。",
@@ -266,8 +266,8 @@
266266 "Use system prompt": "使用系统提示词",
267267 "Merges_all_system_messages_desc_1": "合并所有系统消息,直到第一条具有非系统角色的消息,然后通过",
268268 "Merges_all_system_messages_desc_2": "字段发送。",
269269 "ShowRequest model reasoning": "展示思维链请求思维链",
270270 "DisplayAllows the model's internalto thoughtsreturn inits thethinking responseprocess.": "展示模型在回复时的内部思维链允许模型返回其思维过程。",
271271 "Assistant Prefill": "AI预填",
272272 "Expand the editor": "展开编辑器",
273273 "Start Claude's answer with...": "以如下内容开始Claude的回答...",
@@ -559,7 +559,7 @@
559559 "Prompt Content": "提示词内容",
560560 "Custom Stopping Strings": "自定义停止字符串",
561561 "JSON serialized array of strings": "JSON序列化的字符串数组",
562562 "Replace Macro in Custom StoppingStop Strings": "替换自定义停止字符串中的宏",
563563 "Token Padding": "词符填充",
564564 "Miscellaneous": "杂项",
565565 "Non-markdown strings": "非 Markdown 字符串",
@@ -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": "角色世界书优先",
@@ -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": "触发滑动的黑名单词语数量",
@@ -1208,7 +1208,7 @@
12081208 "View contents": "查看内容",
12091209 "Remove the file": "删除文件",
12101210 "Author's Note": "作者注释",
12111211 "Unique to this chat": "此聊天独有仅对此聊天生效",
12121212 "Checkpoints inherit the Note from their parent, and can be changed individually after that.": "检查点从其父级继承注释,之后可以单独更改。",
12131213 "Include in World Info Scanning": "纳入世界信息扫描",
12141214 "Before Main Prompt / Story String": "主提示词/故事线之前",
@@ -1224,13 +1224,13 @@
12241224 "Replace Author's Note": "替换作者注",
12251225 "Default Author's Note": "默认作者注",
12261226 "Will be automatically added as the Author's Note for all new chats.": "将自动添加为所有新聊天的作者注释。",
12271227 "Chat CFG": "聊天CFG本聊天的CFG缩放",
12281228 "1 = disabled": "“1”为已禁用为禁用",
12291229 "write short replies, write replies using past tense": "写简短的回复,用过去时写回复",
12301230 "Positive Prompt": "正面提示词",
12311231 "Use character CFG scales": "单独为各个角色设置CFG缩放",
12321232 "Character CFG": "角色CFG配置",
12331233 "Will be automatically added as the CFG for this character.": "将自动添加为该角色的 CFG将自动添加到该角色的CFG设置中。",
12341234 "Global CFG": "全局CFG",
12351235 "Will be used as the default CFG options for every chat unless overridden.": "除非被覆盖,否则将用作每次聊天的默认 CFG 选项。",
12361236 "CFG Prompt Cascading": "CFG 提示词级联",
@@ -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",
@@ -1486,7 +1485,7 @@
14861485 "ext_regex_replace_string_placeholder": "使用 {{match}} 包含来自“查找正则表达式”或“$1”、“$2”等的匹配文本作为捕获组。",
14871486 "Trim Out": "修剪掉",
14881487 "ext_regex_trim_placeholder": "在替换之前全局修剪正则表达式匹配中任何不需要的部分。用回车键分隔每个元素。",
14891488 "ext_regex_affects": "影响作用范围",
14901489 "ext_regex_user_input_desc": "用户发送的消息",
14911490 "ext_regex_user_input": "用户输入",
14921491 "ext_regex_ai_input_desc": "从生成式API中获取的信息。",
@@ -1720,9 +1719,9 @@
17201719 "Chat Lorebook for": "聊天知识书",
17211720 "chat_world_template_txt": "选定的世界信息将绑定到此聊天。生成 AI 回复时,\n它将与全球和角色传说书中的条目相结合。",
17221721 "chat_rename_1": "输入聊天的新名称:",
17231722 "chat_rename_2": "注意!!使用已有文件名会导致错误与其他文件重名会导致错误!!",
17241723 "chat_rename_3": "此举会将次聊天与标记为此举会将此聊天与标记为“检查点”的聊天解绑。",
17251724 "chat_rename_4": "不需要在结尾添加 '.JSONL' 后缀)",
17261725 "Enter Checkpoint Name:": "输入检查点名称:",
17271726 "(Leave empty to auto-generate)": "(留空以自动生成)",
17281727 "The currently existing checkpoint will be unlinked and replaced with the new checkpoint, but can still be found in the Chat Management.": "当前检查点将会被解绑并替换为新的检查点,但仍可在聊天管理中找到。",
@@ -1975,7 +1974,7 @@
19751974 "Enter your password below to confirm:": "输入您的密码以确认:",
19761975 "Chat Scenario Override": "聊天场景覆盖",
19771976 "Remove": "移除",
19781977 "Unique to this chat.": "Unique to this chat.仅对此聊天生效。",
19791978 "All group members will use the following scenario text instead of what is specified in their character cards.": "All group members will use the following scenario text instead of what is specified in their character cards.",
19801979 "The following scenario text will be used instead of the value set in the character card.": "The following scenario text will be used instead of the value set in the character card.",
19811980 "Checkpoints inherit the scenario override from their parent, and can be changed individually after that.": "Checkpoints inherit the scenario override from their parent, and can be changed individually after that.",
public/locales/zh-tw.json+6 -7
@@ -483,7 +483,7 @@
483483 "separate with commas w/o space between": "用逗號分隔,之間無空格",
484484 "Custom Stopping Strings": "自訂停止字串",
485485 "JSON serialized array of strings": "JSON 序列化字串數組",
486486 "Replace Macro in Custom StoppingStop Strings": "取代自訂停止字串中的巨集",
487487 "Auto-Continue": "自動繼續",
488488 "Allow for Chat Completion APIs": "允許聊天補全 API",
489489 "Target length (tokens)": "目標長度(符元)",
@@ -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": "滑動的黑名單詞語數量",
@@ -1458,7 +1458,7 @@
14581458 "Example: http://localhost:1234/v1": "例如:http://localhost:1234/v1",
14591459 "popup-button-crop": "裁剪",
14601460 "(disabled when max recursion steps are used)": "(當最大遞歸步驟數使用時將停用)",
14611461 "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(使用最小啟動設定時將停用)",
14621462 "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)。",
14631463 "A multiplicative factor to expand the overall area that the nodes take up.": "節點佔用該擴充功能區域的倍數。",
14641464 "Abort current image generation task": "終止目前的圖片生成任務",
@@ -1653,7 +1653,6 @@
16531653 "HuggingFace Token": "HuggingFace 符元",
16541654 "Image Captioning": "圖片註解",
16551655 "Generate Caption": "產生圖片註解",
1656- "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)",
16571656 "Injection Position": "插入位置",
16581657 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
16591658 "Injection Template": "插入範本",
@@ -1806,7 +1805,7 @@
18061805 "context_derived": "若可能,根據模型元數據推導。",
18071806 "instruct_derived": "若可能,根據模型元數據推導。",
18081807 "Inserted before the first User's message.": "插入於第一則使用者訊息之前。",
18091808 "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(啟用最小啟動次數時無效)",
18101809 "Quick 'Impersonate' button": "快速「AI 扮演使用者」按鈕",
18111810 "Manual": "手動",
18121811 "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)",
@@ -2357,8 +2356,8 @@
23572356 "Forbid": "禁止",
23582357 "Aphrodite only. Determines the order of samplers. Skew is always applied post-softmax, so it's not included here.": "僅限 Aphrodite 使用。決定採樣器的順序。偏移總是在 softmax 後應用,因此不包括在此。",
23592358 "Aphrodite only. Determines the order of samplers.": "僅限 Aphrodite 使用。決定採樣器的順序。",
23602359 "ShowRequest model reasoning": "顯示模型思維鏈請求模型思維鏈",
23612360 "DisplayAllows the model's internalto thoughtsreturn inits thethinking responseprocess.": "在回應中顯示模型的思維鏈(內部思考過程)讓模型回傳其思考過程。",
23622361 "Generic (OpenAI-compatible) [LM Studio, LiteLLM, etc.]": "通用(兼容 OpenAI)[LM Studio, LiteLLM 等]",
23632362 "Model ID (optional)": "模型 ID(可選)",
23642363 "DeepSeek API Key": "DeepSeek API 金鑰",
public/script.js+267 -205
@@ -95,6 +95,7 @@ import {
9595 resetMovableStyles,
9696 forceCharacterEditorTokenize,
9797 applyPowerUserSettings,
98+ generatedTextFiltered,
9899} from './scripts/power-user.js';
99100
100101import {
@@ -169,6 +170,7 @@ import {
169170 toggleDrawer,
170171 isElementInViewport,
171172 copyText,
173+ escapeHtml,
172174} from './scripts/utils.js';
173175import { debounce_timeout } from './scripts/constants.js';
174176
@@ -272,7 +274,8 @@ import { initSettingsSearch } from './scripts/setting-search.js';
272274import { initBulkEdit } from './scripts/bulk-edit.js';
273275import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js';
274276import { getContext } from './scripts/st-context.js';
275277import { extractReasoningFromData, initReasoning, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
278+import { accountStorage } from './scripts/util/AccountStorage.js';
276279
277280// API OBJECT FOR EXTERNAL WIRING
278281globalThis.SillyTavern = {
@@ -368,6 +371,10 @@ DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
368371 return;
369372 }
370373
374+ if (!(node instanceof Element)) {
375+ return;
376+ }
377+
371378 let mediaBlocked = false;
372379
373380 switch (node.tagName) {
@@ -422,7 +429,7 @@ DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
422429 const entityId = getCurrentEntityId();
423430 const warningShownKey = `mediaWarningShown:${entityId}`;
424431
425432 if (localStorageaccountStorage.getItem(warningShownKey) === null) {
426433 const warningToast = toastr.warning(
427434 t`Use the 'Ext. Media' button to allow it. Click on this message to dismiss.`,
428435 t`External media has been blocked`,
@@ -433,7 +440,7 @@ DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
433440 },
434441 );
435442
436443 localStorageaccountStorage.setItem(warningShownKey, 'true');
437444 }
438445 }
439446});
@@ -495,9 +502,11 @@ export const event_types = {
495502 // TODO: Naming convention is inconsistent with other events
496503 CHARACTER_DELETED: 'characterDeleted',
497504 CHARACTER_DUPLICATED: 'character_duplicated',
505+ CHARACTER_RENAMED: 'character_renamed',
498506 /** @deprecated The event is aliased to STREAM_TOKEN_RECEIVED. */
499507 SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received',
500508 STREAM_TOKEN_RECEIVED: 'stream_token_received',
509+ STREAM_REASONING_DONE: 'stream_reasoning_done',
501510 FILE_ATTACHMENT_DELETED: 'file_attachment_deleted',
502511 WORLDINFO_FORCE_ACTIVATE: 'worldinfo_force_activate',
503512 OPEN_CHARACTER_LIBRARY: 'open_character_library',
@@ -508,7 +517,7 @@ export const event_types = {
508517 TOOL_CALLS_RENDERED: 'tool_calls_rendered',
509518};
510519
511520export const eventSource = new EventEmitter([event_types.APP_READY]);
512521
513522eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands);
514523
@@ -1028,12 +1037,22 @@ export function setAnimationDuration(ms = null) {
10281037 document.documentElement.style.setProperty('--animation-duration', `${animation_duration}ms`);
10291038}
10301039
1040+/**
1041+ * Sets the currently active character
1042+ * @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`.
1043+ */
10311044export function setActiveCharacter(entityOrKey) {
10321045 active_character = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
1046+ if (active_character) active_group = null;
10331047}
10341048
1049+/**
1050+ * Sets the currently active group.
1051+ * @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`.
1052+ */
10351053export function setActiveGroup(entityOrKey) {
10361054 active_group = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
1055+ if (active_group) active_character = null;
10371056}
10381057
10391058/**
@@ -1500,7 +1519,7 @@ export async function printCharacters(fullRefresh = false) {
15001519
15011520 $('#rm_print_characters_pagination').pagination({
15021521 dataSource: entities,
15031522 pageSize: Number(localStorageaccountStorage.getItem(storageKey)) || per_page_default,
15041523 sizeChangerOptions: [10, 25, 50, 100, 250, 500, 1000],
15051524 pageRange: 1,
15061525 pageNumber: saveCharactersPage || 1,
@@ -1544,7 +1563,7 @@ export async function printCharacters(fullRefresh = false) {
15441563 eventSource.emit(event_types.CHARACTER_PAGE_LOADED);
15451564 },
15461565 afterSizeSelectorChange: function (e) {
15471566 localStorageaccountStorage.setItem(storageKey, e.target.value);
15481567 },
15491568 afterPaging: function (e) {
15501569 saveCharactersPage = e;
@@ -2007,14 +2026,15 @@ export async function sendTextareaMessage() {
20072026 * @param {boolean} isUser If the message was sent by the user
20082027 * @param {number} messageId Message index in chat array
20092028 * @param {object} [sanitizerOverrides] DOMPurify sanitizer option overrides
2029+ * @param {boolean} [isReasoning] If the message is reasoning output
20102030 * @returns {string} HTML string
20112031 */
20122032export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, sanitizerOverrides = {}, isReasoning = false) {
20132033 if (!mes) {
20142034 return '';
20152035 }
20162036
20172037 if (Number(messageId) === 0 && !isSystem && !isUser && !isReasoning) {
20182038 const mesBeforeReplace = mes;
20192039 const chatMessage = chat[messageId];
20202040 mes = substituteParams(mes, undefined, ch_name);
@@ -2043,6 +2063,9 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
20432063 if (!isSystem) {
20442064 function getRegexPlacement() {
20452065 try {
2066+ if (isReasoning) {
2067+ return regex_placement.REASONING;
2068+ }
20462069 if (isUser) {
20472070 return regex_placement.USER_INPUT;
20482071 } else if (chat[messageId]?.extra?.type === 'narrator') {
@@ -2076,6 +2099,17 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
20762099 mes = mes.replaceAll('<', '&lt;').replaceAll('>', '&gt;');
20772100 }
20782101
2102+ // Make sure reasoning strings are always shown, even if they include "<" or ">"
2103+ [power_user.reasoning.prefix, power_user.reasoning.suffix].forEach((reasoningString) => {
2104+ if (!reasoningString || !reasoningString.trim().length) {
2105+ return;
2106+ }
2107+ // Only replace the first occurrence of the reasoning string
2108+ if (mes.includes(reasoningString)) {
2109+ mes = mes.replace(reasoningString, escapeHtml(reasoningString));
2110+ }
2111+ });
2112+
20792113 if (!isSystem) {
20802114 // Save double quotes in tags as a special character to prevent them from being encoded
20812115 if (!power_user.encode_tags) {
@@ -2186,26 +2220,29 @@ function insertSVGIcon(mes, extra) {
21862220 modelName = extra.api;
21872221 }
21882222
2189- const image = new Image();
2223+ const insertOrReplaceSVG = (image, className, targetSelector, insertBefore) => {
2190- // Add classes for styling and identification
2191- image.classList.add('icon-svg', 'timestamp-icon');
2192- image.src = `/img/${modelName}.svg`;
2193- image.title = `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`;
2194-
21952224 image.onload = async function () {
2196- // Check if an SVG already exists adjacent to the timestamp
2225+ let existingSVG = insertBefore ? mes.find(targetSelector).prev(`.${className}`) : mes.find(targetSelector).next(`.${className}`);
2197- let existingSVG = mes.find('.timestamp').next('.timestamp-icon');
2198-
21992226 if (existingSVG.length) {
2200- // Replace existing SVG
22012227 existingSVG.replaceWith(image);
22022228 } else {
2203- // Append the new SVG if none exists
2229+ if (insertBefore) mes.find(targetSelector).before(image);
22042230 else mes.find('.timestamp'targetSelector).after(image);
22052231 }
2206-
22072232 await SVGInject(image);
22082233 };
2234+ };
2235+
2236+ const createModelImage = (className, targetSelector, insertBefore) => {
2237+ const image = new Image();
2238+ image.classList.add('icon-svg', className);
2239+ image.src = `/img/${modelName}.svg`;
2240+ image.title = `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`;
2241+ insertOrReplaceSVG(image, className, targetSelector, insertBefore);
2242+ };
2243+
2244+ createModelImage('timestamp-icon', '.timestamp');
2245+ createModelImage('thinking-icon', '.mes_reasoning_header_title', true);
22092246}
22102247
22112248
@@ -2216,7 +2253,6 @@ function getMessageFromTemplate({
22162253 isUser,
22172254 avatarImg,
22182255 bias,
2219- reasoning,
22202256 isSystem,
22212257 title,
22222258 timerValue,
@@ -2241,7 +2277,6 @@ function getMessageFromTemplate({
22412277 mes.find('.avatar img').attr('src', avatarImg);
22422278 mes.find('.ch_name .name_text').text(characterName);
22432279 mes.find('.mes_bias').html(bias);
2244- mes.find('.mes_reasoning').html(reasoning);
22452280 mes.find('.timestamp').text(timestamp).attr('title', `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`);
22462281 mes.find('.mesIDDisplay').text(`#${mesId}`);
22472282 tokenCount && mes.find('.tokenCounterDisplay').text(`${tokenCount}t`);
@@ -2249,6 +2284,8 @@ function getMessageFromTemplate({
22492284 timerValue && mes.find('.mes_timer').attr('title', timerTitle).text(timerValue);
22502285 bookmarkLink && updateBookmarkDisplay(mes);
22512286
2287+ updateReasoningUI(mes);
2288+
22522289 if (power_user.timestamp_model_icon && extra?.api) {
22532290 insertSVGIcon(mes, extra);
22542291 }
@@ -2260,12 +2297,18 @@ function getMessageFromTemplate({
22602297 * Re-renders a message block with updated content.
22612298 * @param {number} messageId Message ID
22622299 * @param {object} message Message object
2300+ * @param {object} [options={}] Optional arguments
2301+ * @param {boolean} [options.rerenderMessage=true] Whether to re-render the message content (inside <c>.mes_text</c>)
22632302 */
22642303export function updateMessageBlock(messageId, message, { rerenderMessage = true } = {}) {
22652304 const messageElement = $(`#chat [mesid="${messageId}"]`);
2305+ if (rerenderMessage) {
22662306 const text = message?.extra?.display_text ?? message.mes;
22672307 messageElement.find('.mes_text').html(messageFormatting(text, message.name, message.is_system, message.is_user, messageId, {}, false));
2268- messageElement.find('.mes_reasoning').html(messageFormatting(message.extra?.reasoning ?? '', '', false, false, -1));
2308+ }
2309+
2310+ updateReasoningUI(messageElement);
2311+
22692312 addCopyToCodeBlocks(messageElement);
22702313 appendMediaToMessage(message, messageElement);
22712314}
@@ -2422,9 +2465,9 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
24222465 mes.is_user,
24232466 chat.indexOf(mes),
24242467 sanitizerOverrides,
2468+ false,
24252469 );
24262470 const bias = messageFormatting(mes.extra?.bias ?? '', '', false, false, -1, {}, false);
2427- const reasoning = messageFormatting(mes.extra?.reasoning ?? '', '', false, false, -1);
24282471 let bookmarkLink = mes?.extra?.bookmark_link ?? '';
24292472
24302473 let params = {
@@ -2434,7 +2477,6 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
24342477 isUser: mes.is_user,
24352478 avatarImg: avatarImg,
24362479 bias: bias,
2437- reasoning: reasoning,
24382480 isSystem: isSystem,
24392481 title: title,
24402482 bookmarkLink: bookmarkLink,
@@ -2442,7 +2484,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
24422484 timestamp: timestamp,
24432485 extra: mes.extra,
24442486 tokenCount: mes.extra?.token_count ?? 0,
24452487 ...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration),
24462488 };
24472489
24482490 const renderedMessage = getMessageFromTemplate(params);
@@ -2494,8 +2536,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
24942536 const swipeMessage = chatElement.find(`[mesid="${chat.length - 1}"]`);
24952537 swipeMessage.attr('swipeid', params.swipeId);
24962538 swipeMessage.find('.mes_text').html(messageText).attr('title', title);
2497- swipeMessage.find('.mes_reasoning').html(reasoning);
24982539 swipeMessage.find('.timestamp').text(timestamp).attr('title', `${params.extra.api} - ${params.extra.model}`);
2540+ updateReasoningUI(swipeMessage);
24992541 appendMediaToMessage(mes, swipeMessage);
25002542 if (power_user.timestamp_model_icon && params.extra?.api) {
25012543 insertSVGIcon(swipeMessage, params.extra);
@@ -2562,13 +2604,14 @@ export function formatCharacterAvatar(characterAvatar) {
25622604 * @param {Date} gen_started Date when generation was started
25632605 * @param {Date} gen_finished Date when generation was finished
25642606 * @param {number} tokenCount Number of tokens generated (0 if not available)
2607+ * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done)
25652608 * @returns {Object} Object containing the formatted timer value and title
25662609 * @example
25672610 * const { timerValue, timerTitle } = formatGenerationTimer(gen_started, gen_finished, tokenCount);
25682611 * console.log(timerValue); // 1.2s
25692612 * console.log(timerTitle); // Generation queued: 12:34:56 7 Jan 2021\nReply received: 12:34:57 7 Jan 2021\nTime to generate: 1.2 seconds\nToken rate: 5 t/s
25702613 */
25712614function formatGenerationTimer(gen_started, gen_finished, tokenCount, reasoningDuration = null) {
25722615 if (!gen_started || !gen_finished) {
25732616 return {};
25742617 }
@@ -2582,8 +2625,9 @@ function formatGenerationTimer(gen_started, gen_finished, tokenCount) {
25822625 `Generation queued: ${start.format(dateFormat)}`,
25832626 `Reply received: ${finish.format(dateFormat)}`,
25842627 `Time to generate: ${seconds} seconds`,
2628+ reasoningDuration > 0 ? `Time to think: ${reasoningDuration / 1000} seconds` : '',
25852629 tokenCount > 0 ? `Token rate: ${Number(tokenCount / seconds).toFixed(1)} t/s` : '',
25862630 ].filter(x => x).join('\n').trim();
25872631
25882632 if (isNaN(seconds) || seconds < 0) {
25892633 return { timerValue: '', timerTitle };
@@ -2771,7 +2815,8 @@ export async function generateQuietPrompt(quiet_prompt, quietToLoud, skipWIAN, q
27712815 TempResponseLength.save(main_api, responseLength);
27722816 eventHook = TempResponseLength.setupEventHook(main_api);
27732817 }
27742818 returnconst result = await Generate('quiet', options);
2819+ return removeReasoningFromString(result);
27752820 } finally {
27762821 if (responseLengthCustomized && TempResponseLength.isCustomized()) {
27772822 TempResponseLength.restore(main_api);
@@ -3071,8 +3116,8 @@ export function isStreamingEnabled() {
30713116 (main_api == 'openai' &&
30723117 oai_settings.stream_openai &&
30733118 !noStreamSources.includes(oai_settings.chat_completion_source) &&
30743119 !(oai_settings.chat_completion_source == chat_completion_sources.OPENAI && oai_settings.openai_model.startsWith(['o1-2024-12-17', 'o1'].includes(oai_settings.openai_model)) &&
3075- !(oai_settings.chat_completion_source == chat_completion_sources.MAKERSUITE && oai_settings.google_model.includes('bison')))
3120+ )
30763121 || (main_api == 'kobold' && kai_settings.streaming_kobold && kai_flags.can_use_streaming)
30773122 || (main_api == 'novel' && nai_settings.streaming_novel)
30783123 || (main_api == 'textgenerationwebui' && textgen_settings.streaming));
@@ -3101,11 +3146,14 @@ class StreamingProcessor {
31013146 constructor(type, forceName2, timeStarted, continueMessage) {
31023147 this.result = '';
31033148 this.messageId = -1;
3149+ /** @type {HTMLElement} */
31043150 this.messageDom = null;
3151+ /** @type {HTMLElement} */
31053152 this.messageTextDom = null;
3153+ /** @type {HTMLElement} */
31063154 this.messageTimerDom = null;
3155+ /** @type {HTMLElement} */
31073156 this.messageTokenCounterDom = null;
3108- this.messageReasoningDom = null;
31093157 /** @type {HTMLTextAreaElement} */
31103158 this.sendTextarea = document.querySelector('#send_textarea');
31113159 this.type = type;
@@ -3121,7 +3169,8 @@ class StreamingProcessor {
31213169 /** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */
31223170 this.messageLogprobs = [];
31233171 this.toolCalls = [];
3124- this.reasoning = '';
3172+ // Initialize reasoning in its own handler
3173+ this.reasoningHandler = new ReasoningHandler(timeStarted);
31253174 }
31263175
31273176 #checkDomElements(messageId) {
@@ -3130,8 +3179,8 @@ class StreamingProcessor {
31303179 this.messageTextDom = this.messageDom?.querySelector('.mes_text');
31313180 this.messageTimerDom = this.messageDom?.querySelector('.mes_timer');
31323181 this.messageTokenCounterDom = this.messageDom?.querySelector('.tokenCounterDisplay');
3133- this.messageReasoningDom = this.messageDom?.querySelector('.mes_reasoning');
31343182 }
3183+ this.reasoningHandler.updateDom(messageId);
31353184 }
31363185
31373186 #updateMessageBlockVisibility() {
@@ -3141,22 +3190,12 @@ class StreamingProcessor {
31413190 }
31423191 }
31433192
31443193 showMessageButtonsmarkUIGenStarted(messageId) {
3145- if (messageId == -1) {
3194+ deactivateSendButtons();
3146- return;
3147- }
3148-
3149- showStopButton();
3150- $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'none' });
3151- }
3152-
3153- hideMessageButtons(messageId) {
3154- if (messageId == -1) {
3155- return;
31563195 }
31573196
3158- hideStopButton();
3197+ markUIGenStopped() {
3159- $(`#chat .mes[mesid="${messageId}"] .mes_buttons`).css({ 'display': 'flex' });
3198+ activateSendButtons();
31603199 }
31613200
31623201 async onStartStreaming(text) {
@@ -3165,20 +3204,18 @@ class StreamingProcessor {
31653204 if (this.type == 'impersonate') {
31663205 this.sendTextarea.value = '';
31673206 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
31683207 } else {
3169- else {
3208+ await saveReply(this.type, text, true, '', [], '');
3170- await saveReply(this.type, text, true);
31713209 messageId = chat.length - 1;
31723210 this.#checkDomElements(messageId);
31733211 this.showMessageButtonsmarkUIGenStarted(messageId);
31743212 }
3175-
31763213 hideSwipeButtons();
31773214 scrollChatToBottom();
31783215 return messageId;
31793216 }
31803217
31813218 async onProgressStreaming(messageId, text, isFinal) {
31823219 const isImpersonate = this.type == 'impersonate';
31833220 const isContinue = this.type == 'continue';
31843221
@@ -3190,11 +3227,9 @@ class StreamingProcessor {
31903227
31913228 let processedText = cleanUpMessage(text, isImpersonate, isContinue, !isFinal, this.stoppingStrings);
31923229
3193- // Predict unbalanced asterisks / quotes during streaming
31943230 const charsToBalance = ['*', '"', '```'];
31953231 for (const char of charsToBalance) {
31963232 if (!isFinal && isOdd(countOccurrences(processedText, char))) {
3197- // Add character at the end to balance it
31983233 const separator = char.length > 1 ? '\n' : '';
31993234 processedText = processedText.trimEnd() + separator + char;
32003235 }
@@ -3203,31 +3238,25 @@ class StreamingProcessor {
32033238 if (isImpersonate) {
32043239 this.sendTextarea.value = processedText;
32053240 this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
32063241 } else {
3207- else {
3242+ const mesChanged = chat[messageId]['mes'] !== processedText;
32083243 this.#checkDomElements(messageId);
32093244 this.#updateMessageBlockVisibility();
32103245 const currentTime = new Date();
32113246 chat[messageId]['mes'] = processedText;
32123247 chat[messageId]['gen_started'] = this.timeStarted;
32133248 chat[messageId]['gen_finished'] = currentTime;
3214-
32153249 if (!chat[messageId]['extra']) {
32163250 chat[messageId]['extra'] = {};
32173251 }
32183252
3219- if (this.reasoning) {
3253+ // Update reasoning
3220- chat[messageId]['extra']['reasoning'] = this.reasoning;
3254+ await this.reasoningHandler.process(messageId, mesChanged);
3221- if (this.messageReasoningDom instanceof HTMLElement) {
3255+ processedText = chat[messageId]['mes'];
3222- const formattedReasoning = messageFormatting(this.reasoning, '', false, false, -1);
3223- this.messageReasoningDom.innerHTML = formattedReasoning;
3224- }
3225- }
32263256
3227- // Don't waste time calculating token count for streaming
3257+ // Token count update.
32283258 const tokenCountText = (this.reasoningHandler.reasoning || '') + processedText;
32293259 const currentTokenCount = isFinal && power_user.message_token_count_enabled ? getTokenCount(tokenCountText, 0) : 0;
3230-
32313260 if (currentTokenCount) {
32323261 chat[messageId]['extra']['token_count'] = currentTokenCount;
32333262 if (this.messageTokenCounterDom instanceof HTMLElement) {
@@ -3246,12 +3275,14 @@ class StreamingProcessor {
32463275 chat[messageId].is_system,
32473276 chat[messageId].is_user,
32483277 messageId,
3278+ {},
3279+ false,
32493280 );
32503281 if (this.messageTextDom instanceof HTMLElement) {
32513282 this.messageTextDom.innerHTML = formattedText;
32523283 }
32533284
32543285 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration());
32553286 if (this.messageTimerDom instanceof HTMLElement) {
32563287 this.messageTimerDom.textContent = timePassed.timerValue;
32573288 this.messageTimerDom.title = timePassed.timerTitle;
@@ -3266,10 +3297,12 @@ class StreamingProcessor {
32663297 }
32673298
32683299 async onFinishStreaming(messageId, text) {
32693300 this.hideMessageButtonsmarkUIGenStopped(this.messageId);
32703301 await this.onProgressStreaming(messageId, text, true);
32713302 addCopyToCodeBlocks($(`#chat .mes[mesid="${messageId}"]`));
32723303
3304+ await this.reasoningHandler.finish(messageId);
3305+
32733306 if (Array.isArray(this.swipes) && this.swipes.length > 0) {
32743307 const message = chat[messageId];
32753308 const swipeInfo = {
@@ -3297,39 +3330,11 @@ class StreamingProcessor {
32973330 unblockGeneration();
32983331 generatedPromptCache = '';
32993332
3300- //console.log("Generated text size:", text.length, text)
3301-
33023333 const isAborted = this.abortController.signal.aborted;
33033334 if (!isAborted && power_user.auto_swipe && !isAbortedgeneratedTextFiltered(text)) {
3304- function containsBlacklistedWords(str, blacklist, threshold) {
3335+ return swipe_right();
3305- const regex = new RegExp(`\\b(${blacklist.join('|')})\\b`, 'gi');
3306- const matches = str.match(regex) || [];
3307- return matches.length >= threshold;
3308- }
3309-
3310- const generatedTextFiltered = (text) => {
3311- if (text) {
3312- if (power_user.auto_swipe_minimum_length) {
3313- if (text.length < power_user.auto_swipe_minimum_length && text.length !== 0) {
3314- console.log('Generated text size too small');
3315- return true;
3316- }
3317- }
3318- if (power_user.auto_swipe_blacklist_threshold) {
3319- if (containsBlacklistedWords(text, power_user.auto_swipe_blacklist, power_user.auto_swipe_blacklist_threshold)) {
3320- console.log('Generated text has blacklisted words');
3321- return true;
33223336 }
3323- }
3324- }
3325- return false;
3326- };
33273337
3328- if (generatedTextFiltered(text)) {
3329- swipe_right();
3330- return;
3331- }
3332- }
33333338 playMessageSound();
33343339 }
33353340
@@ -3337,7 +3342,7 @@ class StreamingProcessor {
33373342 this.abortController.abort();
33383343 this.isStopped = true;
33393344
33403345 this.hideMessageButtonsmarkUIGenStopped(this.messageId);
33413346 generatedPromptCache = '';
33423347 unblockGeneration();
33433348
@@ -3387,8 +3392,8 @@ class StreamingProcessor {
33873392 const timestamps = [];
33883393 for await (const { text, swipes, logprobs, toolCalls, state } of this.generator()) {
33893394 timestamps.push(Date.now());
33903395 if (this.isStopped || this.abortController.signal.aborted) {
33913396 return this.result;
33923397 }
33933398
33943399 this.toolCalls = toolCalls;
@@ -3397,9 +3402,10 @@ class StreamingProcessor {
33973402 if (logprobs) {
33983403 this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));
33993404 }
3400- this.reasoning = state?.reasoning ?? '';
3405+ // Get the updated reasoning string into the handler
3406+ this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning);
34013407 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
34023408 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
34033409 }
34043410 const seconds = (timestamps[timestamps.length - 1] - timestamps[0]) / 1000;
34053411 console.warn(`Stream stats: ${timestamps.length} tokens, ${seconds.toFixed(2)} seconds, rate: ${Number(timestamps.length / seconds).toFixed(2)} TPS`);
@@ -3475,7 +3481,7 @@ export async function generateRaw(prompt, api, instructOverride, quietToLoud, sy
34753481 break;
34763482 }
34773483 case 'textgenerationwebui':
34783484 generateData = await getTextGenGenerationData(prompt, amount_gen, false, false, null, 'quiet');
34793485 TempResponseLength.restore(api);
34803486 break;
34813487 case 'openai': {
@@ -3864,14 +3870,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
38643870 coreChat.pop();
38653871 }
38663872
3867- const reasoning = new PromptReasoning();
3868- for (let i = coreChat.length - 1; i >= 0; i--) {
3869- if (reasoning.isLimitReached()) {
3870- break;
3871- }
3872- coreChat[i] = { ...coreChat[i], mes: reasoning.addToMessage(coreChat[i].mes, coreChat[i].extra?.reasoning) };
3873- }
3874-
38753873 coreChat = await Promise.all(coreChat.map(async (chatItem, index) => {
38763874 let message = chatItem.mes;
38773875 let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT;
@@ -3891,6 +3889,27 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
38913889 };
38923890 }));
38933891
3892+ const reasoning = new PromptReasoning();
3893+ for (let i = coreChat.length - 1; i >= 0; i--) {
3894+ const depth = coreChat.length - i - 1;
3895+ const isPrefix = isContinue && i === coreChat.length - 1;
3896+ coreChat[i] = {
3897+ ...coreChat[i],
3898+ mes: reasoning.addToMessage(
3899+ coreChat[i].mes,
3900+ getRegexedString(
3901+ String(coreChat[i].extra?.reasoning ?? ''),
3902+ regex_placement.REASONING,
3903+ { isPrompt: true, depth: depth },
3904+ ),
3905+ isPrefix,
3906+ ),
3907+ };
3908+ if (reasoning.isLimitReached()) {
3909+ break;
3910+ }
3911+ }
3912+
38943913 // Determine token limit
38953914 let this_max_context = getMaxContextSize();
38963915
@@ -4449,7 +4468,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
44494468 // For prompt bit itemization
44504469 let mesSendString = '';
44514470
44524471 async function getCombinedPrompt(isNegative) {
44534472 // Only return if the guidance scale doesn't exist or the value is 1
44544473 // Also don't return if constructing the neutral prompt
44554474 if (isNegative && !useCfgPrompt) {
@@ -4476,10 +4495,16 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
44764495 // TODO: Make all extension prompts use an array/splice method
44774496 const lengthDiff = mesSend.length - cfgPrompt.depth;
44784497 const cfgDepth = lengthDiff >= 0 ? lengthDiff : 0;
4498+ const cfgMessage = finalMesSend[cfgDepth];
4499+ if (cfgMessage) {
4500+ if (!Array.isArray(finalMesSend[cfgDepth].extensionPrompts)) {
4501+ finalMesSend[cfgDepth].extensionPrompts = [];
4502+ }
44794503 finalMesSend[cfgDepth].extensionPrompts.push(`${cfgPrompt.value}\n`);
44804504 }
44814505 }
44824506 }
4507+ }
44834508
44844509 // Add prompt bias after everything else
44854510 // Always run with continue
@@ -4552,13 +4577,13 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
45524577 };
45534578
45544579 // Before returning the combined prompt, give available context related information to all subscribers.
45554580 await eventSource.emitAndWaitemit(event_types.GENERATE_BEFORE_COMBINE_PROMPTS, data);
45564581
45574582 // If one or multiple subscribers return a value, forfeit the responsibillity of flattening the context.
45584583 return !data.combinedPrompt ? combine() : data.combinedPrompt;
45594584 }
45604585
45614586 let finalPrompt = await getCombinedPrompt(false);
45624587
45634588 const eventData = { prompt: finalPrompt, dryRun: dryRun };
45644589 await eventSource.emit(event_types.GENERATE_AFTER_COMBINE_PROMPTS, eventData);
@@ -4592,8 +4617,8 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
45924617 }
45934618 break;
45944619 case 'textgenerationwebui': {
45954620 const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale, negativePrompt: await getCombinedPrompt(true) } : null;
45964621 generate_data = await getTextGenGenerationData(finalPrompt, maxLength, isImpersonate, isContinue, cfgValues, type);
45974622 break;
45984623 }
45994624 case 'novel': {
@@ -4799,6 +4824,11 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
47994824 const swipes = extractMultiSwipes(data, type);
48004825
48014826 messageChunk = cleanUpMessage(getMessage, isImpersonate, isContinue, false);
4827+ reasoning = getRegexedString(reasoning, regex_placement.REASONING);
4828+
4829+ if (power_user.trim_spaces) {
4830+ reasoning = reasoning.trim();
4831+ }
48024832
48034833 if (isContinue) {
48044834 getMessage = continue_mag + getMessage;
@@ -4857,32 +4887,9 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
48574887 }
48584888
48594889 const isAborted = abortController && abortController.signal.aborted;
48604890 if (!isAborted && power_user.auto_swipe && !isAbortedgeneratedTextFiltered(getMessage)) {
4861- console.debug('checking for autoswipeblacklist on non-streaming message');
4862- function containsBlacklistedWords(getMessage, blacklist, threshold) {
4863- console.debug('checking blacklisted words');
4864- const regex = new RegExp(`\\b(${blacklist.join('|')})\\b`, 'gi');
4865- const matches = getMessage.match(regex) || [];
4866- return matches.length >= threshold;
4867- }
4868-
4869- const generatedTextFiltered = (getMessage) => {
4870- if (power_user.auto_swipe_blacklist_threshold) {
4871- if (containsBlacklistedWords(getMessage, power_user.auto_swipe_blacklist, power_user.auto_swipe_blacklist_threshold)) {
4872- console.debug('Generated text has blacklisted words');
4873- return true;
4874- }
4875- }
4876-
4877- return false;
4878- };
4879- if (generatedTextFiltered(getMessage)) {
4880- console.debug('swiping right automatically');
48814891 is_send_press = false;
48824892 return swipe_right();
4883- // TODO: do we want to resolve after an auto-swipe?
4884- return;
4885- }
48864893 }
48874894
48884895 console.debug('/api/chats/save called by /Generate');
@@ -5537,7 +5544,7 @@ async function promptItemize(itemizedPrompts, requestedMesId) {
55375544 toastr.info(t`Copied!`);
55385545 });
55395546
55405547 popup.dlg.querySelector('#showRawPrompt').addEventListener('click', async function () {
55415548 //console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
55425549 console.log(PromptArrayItemForRawPromptDisplay);
55435550 console.log(itemizedPrompts);
@@ -5545,6 +5552,17 @@ async function promptItemize(itemizedPrompts, requestedMesId) {
55455552
55465553 const rawPrompt = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
55475554
5555+ // Mobile needs special handholding. The side-view on the popup wouldn't work,
5556+ // so we just show an additional popup for this.
5557+ if (isMobile()) {
5558+ const content = document.createElement('div');
5559+ content.classList.add('tokenItemizingMaintext');
5560+ content.innerText = rawPrompt;
5561+ const popup = new Popup(content, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true, leftAlign: true });
5562+ await popup.show();
5563+ return;
5564+ }
5565+
55485566 //let DisplayStringifiedPrompt = JSON.stringify(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt).replace(/\n+/g, '<br>');
55495567 const rawPromptWrapper = document.getElementById('rawPromptWrapper');
55505568 rawPromptWrapper.innerText = rawPrompt;
@@ -5728,26 +5746,6 @@ function extractMessageFromData(data) {
57285746}
57295747
57305748/**
5731- * Extracts the reasoning from the response data.
5732- * @param {object} data Response data
5733- * @returns {string} Extracted reasoning
5734- */
5735-function extractReasoningFromData(data) {
5736- if (main_api === 'openai' && oai_settings.show_thoughts) {
5737- switch (oai_settings.chat_completion_source) {
5738- case chat_completion_sources.DEEPSEEK:
5739- return data?.choices?.[0]?.message?.reasoning_content ?? '';
5740- case chat_completion_sources.OPENROUTER:
5741- return data?.choices?.[0]?.message?.reasoning ?? '';
5742- case chat_completion_sources.MAKERSUITE:
5743- return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? '';
5744- }
5745- }
5746-
5747- return '';
5748-}
5749-
5750-/**
57515749 * Extracts multiswipe swipes from the response data.
57525750 * @param {Object} data Response data
57535751 * @param {string} type Type of generation
@@ -5937,6 +5935,15 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes,
59375935 chat[chat.length - 1]['extra'] = {};
59385936 }
59395937
5938+ // Coerce null/undefined to empty string
5939+ if (chat.length && !chat[chat.length - 1]['extra']['reasoning']) {
5940+ chat[chat.length - 1]['extra']['reasoning'] = '';
5941+ }
5942+
5943+ if (!reasoning) {
5944+ reasoning = '';
5945+ }
5946+
59405947 let oldMessage = '';
59415948 const generationFinished = new Date();
59425949 const img = extractImageFromMessage(getMessage);
@@ -5953,6 +5960,7 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes,
59535960 chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
59545961 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
59555962 chat[chat.length - 1]['extra']['reasoning'] = reasoning;
5963+ chat[chat.length - 1]['extra']['reasoning_duration'] = null;
59565964 if (power_user.message_token_count_enabled) {
59575965 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
59585966 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
@@ -5974,7 +5982,8 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes,
59745982 chat[chat.length - 1]['send_date'] = getMessageTimeStamp();
59755983 chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
59765984 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
59775985 chat[chat.length - 1]['extra']['reasoning'] += reasoning;
5986+ chat[chat.length - 1]['extra']['reasoning_duration'] = null;
59785987 if (power_user.message_token_count_enabled) {
59795988 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
59805989 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
@@ -5994,6 +6003,7 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes,
59946003 chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
59956004 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
59966005 chat[chat.length - 1]['extra']['reasoning'] += reasoning;
6006+ // We don't know if the reasoning duration extended, so we don't update it here on purpose.
59976007 if (power_user.message_token_count_enabled) {
59986008 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
59996009 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
@@ -6013,6 +6023,7 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes,
60136023 chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
60146024 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
60156025 chat[chat.length - 1]['extra']['reasoning'] = reasoning;
6026+ chat[chat.length - 1]['extra']['reasoning_duration'] = null;
60166027 if (power_user.trim_spaces) {
60176028 getMessage = getMessage.trim();
60186029 }
@@ -6153,20 +6164,21 @@ function extractImageFromMessage(getMessage) {
61536164 return { getMessage, image, title };
61546165}
61556166
6167+/**
6168+ * A function mainly used to switch 'generating' state - setting it to false and activating the buttons again
6169+ */
61566170export function activateSendButtons() {
61576171 is_send_press = false;
6158- $('#send_but').removeClass('displayNone');
6159- $('#mes_continue').removeClass('displayNone');
6160- $('#mes_impersonate').removeClass('displayNone');
6161- $('.mes_buttons:last').show();
61626172 hideStopButton();
6173+ delete document.body.dataset.generating;
61636174}
61646175
6176+/**
6177+ * A function mainly used to switch 'generating' state - setting it to true and deactivating the buttons
6178+ */
61656179export function deactivateSendButtons() {
6166- $('#send_but').addClass('displayNone');
6167- $('#mes_continue').addClass('displayNone');
6168- $('#mes_impersonate').addClass('displayNone');
61696180 showStopButton();
6181+ document.body.dataset.generating = 'true';
61706182}
61716183
61726184export function resetChatState() {
@@ -6259,9 +6271,35 @@ export async function renameCharacter(name = null, { silent = false, renameChats
62596271 const data = await response.json();
62606272 const newAvatar = data.avatar;
62616273
6262- // Replace tags list
6274+ const oldName = getCharaFilename(null, { manualAvatarKey: oldAvatar });
6275+ const newName = getCharaFilename(null, { manualAvatarKey: newAvatar });
6276+
6277+ // Replace other auxillery fields where was referenced by avatar key
6278+ // Tag List
62636279 renameTagKey(oldAvatar, newAvatar);
62646280
6281+ // Addtional lore books
6282+ const charLore = world_info.charLore?.find(x => x.name == oldName);
6283+ if (charLore) {
6284+ charLore.name = newName;
6285+ saveSettingsDebounced();
6286+ }
6287+
6288+ // Char-bound Author's Notes
6289+ const charNote = extension_settings.note.chara?.find(x => x.name == oldName);
6290+ if (charNote) {
6291+ charNote.name = newName;
6292+ saveSettingsDebounced();
6293+ }
6294+
6295+ // Update active character, if the current one was the currently active one
6296+ if (active_character === oldAvatar) {
6297+ active_character = newAvatar;
6298+ saveSettingsDebounced();
6299+ }
6300+
6301+ await eventSource.emit(event_types.CHARACTER_RENAMED, oldAvatar, newAvatar);
6302+
62656303 // Reload characters list
62666304 await getCharacters();
62676305
@@ -6868,10 +6906,11 @@ export async function getSettings() {
68686906 $('#your_name').text(name1);
68696907 }
68706908
6909+ accountStorage.init(settings?.accountStorage);
68716910 await setUserControls(data.enable_accounts);
68726911
68736912 // Allow subscribers to mutate settings
68746913 await eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);
68756914
68766915 //Load KoboldAI settings
68776916 koboldai_setting_names = data.koboldai_setting_names;
@@ -6968,7 +7007,7 @@ export async function getSettings() {
69687007 loadProxyPresets(settings);
69697008
69707009 // Allow subscribers to mutate settings
69717010 await eventSource.emit(event_types.SETTINGS_LOADED_AFTER, settings);
69727011
69737012 // Set context size after loading power user (may override the max value)
69747013 $('#max_context').val(max_context);
@@ -7028,7 +7067,7 @@ export async function getSettings() {
70287067 }
70297068 await validateDisabledSamplers();
70307069 settingsReady = true;
70317070 await eventSource.emit(event_types.SETTINGS_LOADED);
70327071}
70337072
70347073function selectKoboldGuiPreset() {
@@ -7039,7 +7078,8 @@ function selectKoboldGuiPreset() {
70397078
70407079export async function saveSettings(loopCounter = 0) {
70417080 if (!settingsReady) {
70427081 console.warn('Settings not ready, abortingscheduling another save');
7082+ saveSettingsDebounced();
70437083 return;
70447084 }
70457085
@@ -7060,6 +7100,7 @@ export async function saveSettings(loopCounter = 0) {
70607100 url: '/api/settings/save',
70617101 data: JSON.stringify({
70627102 firstRun: firstRun,
7103+ accountStorage: accountStorage.getState(),
70637104 currentVersion: currentVersion,
70647105 username: name1,
70657106 active_character: active_character,
@@ -7125,8 +7166,10 @@ export function setGenerationParamsFromPreset(preset) {
71257166// Common code for message editor done and auto-save
71267167function updateMessage(div) {
71277168 const mesBlock = div.closest('.mes_block');
71287169 let text = mesBlock.find('.edit_textarea').val();
7129- const mes = chat[this_edit_mes_id];
7170+ ?? mesBlock.find('.mes_text').text();
7171+ const mesElement = div.closest('.mes');
7172+ const mes = chat[mesElement.attr('mesid')];
71307173
71317174 let regexPlacement;
71327175 if (mes.is_user) {
@@ -7210,9 +7253,11 @@ function messageEditAuto(div) {
72107253 mes.is_system,
72117254 mes.is_user,
72127255 this_edit_mes_id,
7256+ {},
7257+ false,
72137258 ));
72147259 mesBlock.find('.mes_bias').empty();
72157260 mesBlock.find('.mes_bias').append(messageFormatting(bias, '', false, false, -1, {}, false));
72167261 saveChatDebounced();
72177262}
72187263
@@ -7234,13 +7279,20 @@ async function messageEditDone(div) {
72347279 mes.is_system,
72357280 mes.is_user,
72367281 this_edit_mes_id,
7282+ {},
7283+ false,
72377284 ),
72387285 );
72397286 mesBlock.find('.mes_bias').empty();
72407287 mesBlock.find('.mes_bias').append(messageFormatting(bias, '', false, false, -1, {}, false));
72417288 appendMediaToMessage(mes, div.closest('.mes'));
72427289 addCopyToCodeBlocks(div.closest('.mes'));
72437290
7291+ const reasoningEditDone = mesBlock.find('.mes_reasoning_edit_done:visible');
7292+ if (reasoningEditDone.length > 0) {
7293+ reasoningEditDone.trigger('click');
7294+ }
7295+
72447296 await eventSource.emit(event_types.MESSAGE_UPDATED, this_edit_mes_id);
72457297 this_edit_mes_id = undefined;
72467298 await saveChatConditional();
@@ -7504,7 +7556,7 @@ export function select_rm_info(type, charId, previousCharId = null) {
75047556 }
75057557
75067558 try {
75077559 const perPage = Number(localStorageaccountStorage.getItem('Characters_PerPage')) || per_page_default;
75087560 const page = Math.floor(charIndex / perPage) + 1;
75097561 const selector = `#rm_print_characters_block [title*="${avatarFileName}"]`;
75107562 $('#rm_print_characters_pagination').pagination('go', page);
@@ -7536,7 +7588,7 @@ export function select_rm_info(type, charId, previousCharId = null) {
75367588 return;
75377589 }
75387590
75397591 const perPage = Number(localStorageaccountStorage.getItem('Characters_PerPage')) || per_page_default;
75407592 const page = Math.floor(charIndex / perPage) + 1;
75417593 $('#rm_print_characters_pagination').pagination('go', page);
75427594 const selector = `#rm_print_characters_block [grid="${charId}"]`;
@@ -8741,11 +8793,6 @@ const swipe_right = () => {
87418793 easing: animation_easing,
87428794 queue: false,
87438795 complete: async function () {
8744- /*if (!selected_group) {
8745- var typingIndicator = $("#typing_indicator_template .typing_indicator").clone();
8746- typingIndicator.find(".typing_indicator_name").text(characters[this_chid].name);
8747- } */
8748- /* $("#chat").append(typingIndicator); */
87498796 const is_animation_scroll = ($('#chat').scrollTop() >= ($('#chat').prop('scrollHeight') - $('#chat').outerHeight()) - 10);
87508797 //console.log(parseInt(chat[chat.length-1]['swipe_id']));
87518798 //console.log(chat[chat.length-1]['swipes'].length);
@@ -8756,7 +8803,7 @@ const swipe_right = () => {
87568803 // resets the timer
87578804 swipeMessage.find('.mes_timer').html('');
87588805 swipeMessage.find('.tokenCounterDisplay').text('');
8759- swipeMessage.find('.mes_reasoning').html('');
8806+ updateReasoningUI(swipeMessage, { reset: true });
87608807 } else {
87618808 //console.log('showing previously generated swipe candidate, or "..."');
87628809 //console.log('onclick right swipe calling addOneMessage');
@@ -8806,7 +8853,6 @@ const swipe_right = () => {
88068853 if (run_generate && !is_send_press && parseInt(chat[chat.length - 1]['swipe_id']) === chat[chat.length - 1]['swipes'].length) {
88078854 console.debug('caught here 2');
88088855 is_send_press = true;
8809- $('.mes_buttons:last').hide();
88108856 await Generate('swipe');
88118857 } else {
88128858 if (parseInt(chat[chat.length - 1]['swipe_id']) !== chat[chat.length - 1]['swipes'].length) {
@@ -9408,6 +9454,9 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
94089454 continue;
94099455 }
94109456
9457+ accountStorage.removeItem(`AlertWI_${character.avatar}`);
9458+ accountStorage.removeItem(`AlertRegex_${character.avatar}`);
9459+ accountStorage.removeItem(`mediaWarningShown:${character.avatar}`);
94119460 delete tag_map[character.avatar];
94129461 select_rm_info('char_delete', character.name);
94139462
@@ -9610,8 +9659,8 @@ function addDebugFunctions() {
96109659 });
96119660
96129661 registerDebugFunction('toggleRegenerateWarning', 'Toggle Ctrl+Enter regeneration confirmation', 'Toggle the warning when regenerating a message with a Ctrl+Enter hotkey.', () => {
96139662 localStorageaccountStorage.setItem('RegenerateWithCtrlEnter', localStorageaccountStorage.getItem('RegenerateWithCtrlEnter') === 'true' ? 'false' : 'true');
96149663 toastr.info('Regenerate warning is now ' + (localStorageaccountStorage.getItem('RegenerateWithCtrlEnter') === 'true' ? 'disabled' : 'enabled'));
96159664 });
96169665
96179666 registerDebugFunction('copySetup', 'Copy ST setup to clipboard [WIP]', 'Useful data when reporting bugs', async () => {
@@ -10796,6 +10845,12 @@ jQuery(async function () {
1079610845 var edit_mes_id = $(this).closest('.mes').attr('mesid');
1079710846 this_edit_mes_id = edit_mes_id;
1079810847
10848+ // Also edit reasoning, if it exists
10849+ const reasoningEdit = $(this).closest('.mes_block').find('.mes_reasoning_edit:visible');
10850+ if (reasoningEdit.length > 0) {
10851+ reasoningEdit.trigger('click');
10852+ }
10853+
1079910854 var text = chat[edit_mes_id]['mes'];
1080010855 if (chat[edit_mes_id]['is_user']) {
1080110856 this_edit_mes_chname = name1;
@@ -10923,10 +10978,17 @@ jQuery(async function () {
1092310978 chat[this_edit_mes_id].is_system,
1092410979 chat[this_edit_mes_id].is_user,
1092510980 this_edit_mes_id,
10981+ {},
10982+ false,
1092610983 ));
1092710984 appendMediaToMessage(chat[this_edit_mes_id], $(this).closest('.mes'));
1092810985 addCopyToCodeBlocks($(this).closest('.mes'));
1092910986
10987+ const reasoningEditDone = $(this).closest('.mes_block').find('.mes_reasoning_edit_cancel:visible');
10988+ if (reasoningEditDone.length > 0) {
10989+ reasoningEditDone.trigger('click');
10990+ }
10991+
1093010992 await eventSource.emit(event_types.MESSAGE_UPDATED, this_edit_mes_id);
1093110993 this_edit_mes_id = undefined;
1093210994 });
@@ -10990,7 +11052,7 @@ jQuery(async function () {
1099011052 });
1099111053
1099211054 $(document).on('click', '.mes_edit_copy', async function () {
1099311055 const confirmation = await callGenericPopup('t`Create a copy of this message?'`, POPUP_TYPE.CONFIRM);
1099411056 if (!confirmation) {
1099511057 return;
1099611058 }
@@ -11469,7 +11531,7 @@ jQuery(async function () {
1146911531 );
1147011532 break;*/
1147111533 default:
1147211534 await eventSource.emit('charManagementDropdown', target);
1147311535 }
1147411536 $('#char-management-dropdown').prop('selectedIndex', 0);
1147511537 });
@@ -11631,7 +11693,7 @@ jQuery(async function () {
1163111693
1163211694 $(document).on('click', '.open_characters_library', async function () {
1163311695 await getCharacters();
1163411696 await eventSource.emit(event_types.OPEN_CHARACTER_LIBRARY);
1163511697 });
1163611698
1163711699 // Added here to prevent execution before script.js is loaded and get rid of quirky timeouts
public/scripts/RossAscends-mods.js+58 -30
@@ -27,7 +27,6 @@ import {
2727 send_on_enter_options,
2828} from './power-user.js';
2929
30-import { LoadLocal, SaveLocal, LoadLocalBool } from './f-localStorage.js';
3130import { selected_group, is_group_generating, openGroupById } from './group-chats.js';
3231import { getTagKeyForEntity, applyTagsOnCharacterSelect } from './tags.js';
3332import {
@@ -41,6 +40,8 @@ import { textgen_types, textgenerationwebui_settings as textgen_settings, getTex
4140import { debounce_timeout } from './constants.js';
4241
4342import { Popup } from './popup.js';
43+import { accountStorage } from './util/AccountStorage.js';
44+import { getCurrentUserHandle } from './user.js';
4445
4546var RPanelPin = document.getElementById('rm_button_panel_pin');
4647var LPanelPin = document.getElementById('lm_button_panel_pin');
@@ -279,17 +280,32 @@ async function RA_autoloadchat() {
279280 // active character is the name, we should look it up in the character list and get the id
280281 if (active_character !== null && active_character !== undefined) {
281282 const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character);
282283 if (active_character_id !== null-1) {
283284 await selectCharacterById(active_character_id);
284285
285286 // Do a little tomfoolery to spoof the tag selector
286287 const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`);
287288 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.`);
288293 }
289294 }
290295
291296 if (active_group !== null && active_group !== undefined) {
292- 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+ }
293309 }
294310
295311 // if the character list hadn't been loaded yet, try again.
@@ -409,32 +425,34 @@ function RA_autoconnect(PrevApi) {
409425function OpenNavPanels() {
410426 if (!isMobile()) {
411427 //auto-open R nav if locked and previously open
412428 if (LoadLocalBoolaccountStorage.getItem('NavLockOn') == 'true' && LoadLocalBoolaccountStorage.getItem('NavOpened') == 'true') {
413429 //console.log("RA -- clicking right nav to open");
414430 $('#rightNavDrawerIcon').click();
415431 }
416432
417433 //auto-open L nav if locked and previously open
418434 if (LoadLocalBoolaccountStorage.getItem('LNavLockOn') == 'true' && LoadLocalBoolaccountStorage.getItem('LNavOpened') == 'true') {
419435 console.debug('RA -- clicking left nav to open');
420436 $('#leftNavDrawerIcon').click();
421437 }
422438
423439 //auto-open WI if locked and previously open
424440 if (LoadLocalBoolaccountStorage.getItem('WINavLockOn') == 'true' && LoadLocalBoolaccountStorage.getItem('WINavOpened') == 'true') {
425441 console.debug('RA -- clicking WI to open');
426442 $('#WIDrawerIcon').click();
427443 }
428444 }
429445}
430446
447+const getUserInputKey = () => getCurrentUserHandle() + '_userInput';
448+
431449function restoreUserInput() {
432450 if (!power_user.restore_user_input) {
433451 console.debug('restoreUserInput disabled');
434452 return;
435453 }
436454
437455 const userInput = LoadLocallocalStorage.getItem('userInput'getUserInputKey());
438456 if (userInput) {
439457 $('#send_textarea').val(userInput)[0].dispatchEvent(new Event('input', { bubbles: true }));
440458 }
@@ -442,7 +460,8 @@ function restoreUserInput() {
442460
443461function saveUserInput() {
444462 const userInput = String($('#send_textarea').val());
445463 SaveLocallocalStorage.setItem('userInput'getUserInputKey(), userInput);
464+ console.debug('User Input -- ', userInput);
446465}
447466const saveUserInputDebounced = debounce(saveUserInput);
448467
@@ -739,7 +758,7 @@ export function initRossMods() {
739758
740759 //toggle pin class when lock toggle clicked
741760 $(RPanelPin).on('click', function () {
742761 SaveLocalaccountStorage.setItem('NavLockOn', $(RPanelPin).prop('checked'));
743762 if ($(RPanelPin).prop('checked') == true) {
744763 //console.log('adding pin class to right nav');
745764 $(RightNavPanel).addClass('pinnedOpen');
@@ -757,7 +776,7 @@ export function initRossMods() {
757776 }
758777 });
759778 $(LPanelPin).on('click', function () {
760779 SaveLocalaccountStorage.setItem('LNavLockOn', $(LPanelPin).prop('checked'));
761780 if ($(LPanelPin).prop('checked') == true) {
762781 //console.log('adding pin class to Left nav');
763782 $(LeftNavPanel).addClass('pinnedOpen');
@@ -776,7 +795,7 @@ export function initRossMods() {
776795 });
777796
778797 $(WIPanelPin).on('click', function () {
779798 SaveLocalaccountStorage.setItem('WINavLockOn', $(WIPanelPin).prop('checked'));
780799 if ($(WIPanelPin).prop('checked') == true) {
781800 console.debug('adding pin class to WI');
782801 $(WorldInfo).addClass('pinnedOpen');
@@ -796,8 +815,8 @@ export function initRossMods() {
796815 });
797816
798817 // read the state of right Nav Lock and apply to rightnav classlist
799818 $(RPanelPin).prop('checked', LoadLocalBoolaccountStorage.getItem('NavLockOn') == 'true');
800819 if (LoadLocalBoolaccountStorage.getItem('NavLockOn') == 'true') {
801820 //console.log('setting pin class via local var');
802821 $(RightNavPanel).addClass('pinnedOpen');
803822 $(RightNavDrawerIcon).addClass('drawerPinnedOpen');
@@ -808,8 +827,8 @@ export function initRossMods() {
808827 $(RightNavDrawerIcon).addClass('drawerPinnedOpen');
809828 }
810829 // read the state of left Nav Lock and apply to leftnav classlist
811830 $(LPanelPin).prop('checked', LoadLocalBoolaccountStorage.getItem('LNavLockOn') === 'true');
812831 if (LoadLocalBoolaccountStorage.getItem('LNavLockOn') == 'true') {
813832 //console.log('setting pin class via local var');
814833 $(LeftNavPanel).addClass('pinnedOpen');
815834 $(LeftNavDrawerIcon).addClass('drawerPinnedOpen');
@@ -821,8 +840,8 @@ export function initRossMods() {
821840 }
822841
823842 // read the state of left Nav Lock and apply to leftnav classlist
824843 $(WIPanelPin).prop('checked', LoadLocalBoolaccountStorage.getItem('WINavLockOn') === 'true');
825844 if (LoadLocalBoolaccountStorage.getItem('WINavLockOn') == 'true') {
826845 //console.log('setting pin class via local var');
827846 $(WorldInfo).addClass('pinnedOpen');
828847 $(WIDrawerIcon).addClass('drawerPinnedOpen');
@@ -837,22 +856,22 @@ export function initRossMods() {
837856 //save state of Right nav being open or closed
838857 $('#rightNavDrawerIcon').on('click', function () {
839858 if (!$('#rightNavDrawerIcon').hasClass('openIcon')) {
840859 SaveLocalaccountStorage.setItem('NavOpened', 'true');
841860 } else { SaveLocalaccountStorage.setItem('NavOpened', 'false'); }
842861 });
843862
844863 //save state of Left nav being open or closed
845864 $('#leftNavDrawerIcon').on('click', function () {
846865 if (!$('#leftNavDrawerIcon').hasClass('openIcon')) {
847866 SaveLocalaccountStorage.setItem('LNavOpened', 'true');
848867 } else { SaveLocalaccountStorage.setItem('LNavOpened', 'false'); }
849868 });
850869
851870 //save state of Left nav being open or closed
852871 $('#WorldInfo').on('click', function () {
853872 if (!$('#WorldInfo').hasClass('openIcon')) {
854873 SaveLocalaccountStorage.setItem('WINavOpened', 'true');
855874 } else { SaveLocalaccountStorage.setItem('WINavOpened', 'false'); }
856875 });
857876
858877 var chatbarInFocus = false;
@@ -868,8 +887,8 @@ export function initRossMods() {
868887 OpenNavPanels();
869888 }, 300);
870889
871890 $(SelectedCharacterTab).click(function () { SaveLocalaccountStorage.setItem('SelectedNavTab', 'rm_button_selected_ch'); });
872891 $('#rm_button_characters').click(function () { SaveLocalaccountStorage.setItem('SelectedNavTab', 'rm_button_characters'); });
873892
874893 // when a char is selected from the list, save them as the auto-load character for next page load
875894
@@ -1063,14 +1082,21 @@ export function initRossMods() {
10631082 // Ctrl+Enter for Regeneration Last Response. If editing, accept the edits instead
10641083 if (event.ctrlKey && event.key == 'Enter') {
10651084 const editMesDone = $('.mes_edit_done:visible');
1085+ const reasoningMesDone = $('.mes_reasoning_edit_done:visible');
10661086 if (editMesDone.length > 0) {
10671087 console.debug('Accepting edits with Ctrl+Enter');
10681088 $('#send_textarea').focustrigger('focus');
10691089 editMesDone.trigger('click');
10701090 return;
10711091 } else if (is_send_pressreasoningMesDone.length ==> false0) {
1092+ console.debug('Accepting edits with Ctrl+Enter');
1093+ $('#send_textarea').trigger('focus');
1094+ reasoningMesDone.trigger('click');
1095+ return;
1096+ }
1097+ else if (is_send_press == false) {
10721098 const skipConfirmKey = 'RegenerateWithCtrlEnter';
10731099 const skipConfirm = LoadLocalBoolaccountStorage.getItem(skipConfirmKey) === 'true';
10741100 function doRegenerate() {
10751101 console.debug('Regenerating with Ctrl+Enter');
10761102 $('#option_regenerate').trigger('click');
@@ -1082,13 +1108,15 @@ export function initRossMods() {
10821108 let regenerateWithCtrlEnter = false;
10831109 const result = await Popup.show.confirm('Regenerate Message', 'Are you sure you want to regenerate the latest message?', {
10841110 customInputs: [{ id: 'regenerateWithCtrlEnter', label: 'Don\'t ask again' }],
10851111 onClose: (popup) => regenerateWithCtrlEnter = popup.inputResults.get('regenerateWithCtrlEnter') ?? false,{
1112+ regenerateWithCtrlEnter = popup.inputResults.get('regenerateWithCtrlEnter') ?? false;
1113+ },
10861114 });
10871115 if (!result) {
10881116 return;
10891117 }
10901118
10911119 SaveLocalaccountStorage.setItem(skipConfirmKey, String(regenerateWithCtrlEnter));
10921120 doRegenerate();
10931121 }
10941122 return;
public/scripts/authors-note.js+1 -1
@@ -566,7 +566,7 @@ export function initAuthorsNote() {
566566 namedArgumentList: [],
567567 unnamedArgumentList: [
568568 new SlashCommandArgument(
569569 'positionrole', [ARGUMENT_TYPE.STRING], false, false, null, ['system', 'user', 'assistant'],
570570 ),
571571 ],
572572 helpString: `
public/scripts/backgrounds.js+15 -5
@@ -96,8 +96,13 @@ function highlightLockedBackground() {
9696 });
9797}
9898
99+/**
100+ * Locks the background for the current chat
101+ * @param {Event} e Click event
102+ * @returns {string} Empty string
103+ */
99104function onLockBackgroundClick(e) {
100105 e?.stopPropagation();
101106
102107 const chatName = getCurrentChatId();
103108
@@ -106,7 +111,7 @@ function onLockBackgroundClick(e) {
106111 return '';
107112 }
108113
109114 const relativeBgImage = getUrlParameter(this) ?? background_settings.url;
110115
111116 saveBackgroundMetadata(relativeBgImage);
112117 setCustomBackground();
@@ -114,8 +119,13 @@ function onLockBackgroundClick(e) {
114119 return '';
115120}
116121
122+/**
123+ * Locks the background for the current chat
124+ * @param {Event} e Click event
125+ * @returns {string} Empty string
126+ */
117127function onUnlockBackgroundClick(e) {
118128 e?.stopPropagation();
119129 removeBackgroundMetadata();
120130 unsetCustomBackground();
121131 highlightLockedBackground();
@@ -513,12 +523,12 @@ export function initBackgrounds() {
513523 $('#add_bg_button').on('change', onBackgroundUploadSelected);
514524 $('#bg-filter').on('input', onBackgroundFilterInput);
515525 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'lockbg',
516- callback: onLockBackgroundClick,
526+ callback: () => onLockBackgroundClick(new CustomEvent('click')),
517527 aliases: ['bglock'],
518528 helpString: 'Locks a background for the currently selected chat',
519529 }));
520530 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'unlockbg',
521- callback: onUnlockBackgroundClick,
531+ callback: () => onUnlockBackgroundClick(new CustomEvent('click')),
522532 aliases: ['bgunlock'],
523533 helpString: 'Unlocks a background for the currently selected chat',
524534 }));
public/scripts/chat-templates.js+2 -1
@@ -69,6 +69,7 @@ const hash_derivations = {
6969 // DeepSeek R1
7070 'b6835114b7303ddd78919a82e4d9f7d8c26ed0d7dfc36beeb12d524f6144eab1':
7171 'DeepSeek-V2.5'
72+ ,
7273};
7374
7475const substr_derivations = {
@@ -97,6 +98,6 @@ export async function deriveTemplatesFromChatTemplate(chat_template, hash) {
9798 }
9899 }
99100
100101 console.logwarn(`Unknown chat template hash: ${hash} for [${chat_template}]`);
101102 return null;
102103}
public/scripts/chats.js+43 -7
@@ -45,6 +45,7 @@ import { DragAndDropHandler } from './dragdrop.js';
4545import { renderTemplateAsync } from './templates.js';
4646import { t } from './i18n.js';
4747import { humanizedDateTime } from './RossAscends-mods.js';
48+import { accountStorage } from './util/AccountStorage.js';
4849
4950/**
5051 * @typedef {Object} FileAttachment
@@ -621,21 +622,56 @@ async function enlargeMessageImage() {
621622}
622623
623624async function deleteMessageImage() {
624625 const value = await callGenericPopup('<h3>Delete image from message?<br>This action can\'t be undone.</h3>', POPUP_TYPE.CONFIRM);TEXT, '', {
626+ okButton: t`Delete one`,
627+ customButtons: [
628+ {
629+ text: t`Delete all`,
630+ appendAtEnd: true,
631+ result: POPUP_RESULT.CUSTOM1,
632+ },
633+ {
634+ text: t`Cancel`,
635+ appendAtEnd: true,
636+ result: POPUP_RESULT.CANCELLED,
637+ },
638+ ],
639+ });
625640
626641 if (value !== POPUP_RESULT.AFFIRMATIVEvalue) {
627642 return;
628643 }
629644
630645 const mesBlock = $(this).closest('.mes');
631646 const mesId = mesBlock.attr('mesid');
632647 const message = chat[mesId];
648+
649+ let isLastImage = true;
650+
651+ if (Array.isArray(message.extra.image_swipes)) {
652+ const indexOf = message.extra.image_swipes.indexOf(message.extra.image);
653+ if (indexOf > -1) {
654+ message.extra.image_swipes.splice(indexOf, 1);
655+ isLastImage = message.extra.image_swipes.length === 0;
656+ if (!isLastImage) {
657+ const newIndex = Math.min(indexOf, message.extra.image_swipes.length - 1);
658+ message.extra.image = message.extra.image_swipes[newIndex];
659+ }
660+ }
661+ }
662+
663+ if (isLastImage || value === POPUP_RESULT.CUSTOM1) {
633664 delete message.extra.image;
634665 delete message.extra.inline_image;
635666 delete message.extra.title;
636667 delete message.extra.append_title;
668+ delete message.extra.image_swipes;
637669 mesBlock.find('.mes_img_container').removeClass('img_extra');
638670 mesBlock.find('.mes_img').attr('src', '');
671+ } else {
672+ appendMediaToMessage(message, mesBlock);
673+ }
674+
639675 await saveChatConditional();
640676}
641677
@@ -1043,8 +1079,8 @@ async function openAttachmentManager() {
10431079 renderAttachments();
10441080 });
10451081
10461082 let sortField = localStorageaccountStorage.getItem('DataBank_sortField') || 'created';
10471083 let sortOrder = localStorageaccountStorage.getItem('DataBank_sortOrder') || 'desc';
10481084 let filterString = '';
10491085
10501086 const template = $(await renderExtensionTemplateAsync('attachments', 'manager', {}));
@@ -1060,8 +1096,8 @@ async function openAttachmentManager() {
10601096
10611097 sortField = this.selectedOptions[0].dataset.sortField;
10621098 sortOrder = this.selectedOptions[0].dataset.sortOrder;
10631099 localStorageaccountStorage.setItem('DataBank_sortField', sortField);
10641100 localStorageaccountStorage.setItem('DataBank_sortOrder', sortOrder);
10651101 renderAttachments();
10661102 });
10671103 function handleBulkAction(action) {
@@ -1451,7 +1487,7 @@ jQuery(function () {
14511487 ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),
14521488 ];
14531489
14541490 download(JSONchatToSave.stringifymap(chatToSave,(m) null,=> 4JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonjsonl`, 'application/json');
14551491 });
14561492
14571493 // Do not change. #attachFile is added by extension.
public/scripts/extensions.js+19 -8
@@ -9,6 +9,7 @@ import { getContext } from './st-context.js';
99import { isAdmin } from './user.js';
1010import { t } from './i18n.js';
1111import { debounce_timeout } from './constants.js';
12+import { accountStorage } from './util/AccountStorage.js';
1213
1314export {
1415 getContext,
@@ -153,8 +154,18 @@ export const extension_settings = {
153154 refine_mode: false,
154155 },
155156 expressions: {
157+ /** @type {number} see `EXPRESSION_API` */
158+ api: undefined,
156159 /** @type {string[]} */
157160 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,
158169 },
159170 connectionManager: {
160171 selectedProfile: '',
@@ -602,12 +613,12 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
602613 }
603614
604615 let toggleElement = isActive || isDisabled ?
605616 `'<input type="checkbox" title="' + t`Click to toggle` + `" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :
606617 `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;
607618
608619 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>` : '';
609620 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>` : '';
610621 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>` : '';
611622 let modulesInfo = '';
612623
613624 if (isActive && Array.isArray(manifest.optional)) {
@@ -615,7 +626,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
615626 modules.forEach(x => optional.delete(x));
616627 if (optional.size > 0) {
617628 const optionalString = DOMPurify.sanitize([...optional].join(', '));
618629 modulesInfo = `'<div class="extension_modules">' + t`Optional modules:` + ` <span class="optional">${optionalString}</span></div>`;
619630 }
620631 } else if (!isDisabled) { // Neither active nor disabled
621632 const requirements = new Set(manifest.requires);
@@ -714,7 +725,7 @@ async function showExtensionsDetails() {
714725 htmlExternal.append(htmlLoading);
715726
716727 const sortOrderKey = 'extensions_sortByName';
717728 const sortByName = localStorageaccountStorage.getItem(sortOrderKey) === 'true';
718729 const sortFn = sortByName ? sortManifestsByName : sortManifestsByOrder;
719730 const extensions = Object.entries(manifests).sort((a, b) => sortFn(a[1], b[1])).map(getExtensionData);
720731
@@ -745,7 +756,7 @@ async function showExtensionsDetails() {
745756 text: sortByName ? t`Sort: Display Name` : t`Sort: Loading Order`,
746757 action: async () => {
747758 abortController.abort();
748759 localStorageaccountStorage.setItem(sortOrderKey, sortByName ? 'false' : 'true');
749760 await showExtensionsDetails();
750761 },
751762 };
@@ -1153,11 +1164,11 @@ async function checkForExtensionUpdates(force) {
11531164 const currentDate = new Date().toDateString();
11541165
11551166 // Don't nag more than once a day
11561167 if (localStorageaccountStorage.getItem(STORAGE_NAG_KEY) === currentDate) {
11571168 return;
11581169 }
11591170
11601171 localStorageaccountStorage.setItem(STORAGE_NAG_KEY, currentDate);
11611172 }
11621173
11631174 const isCurrentUserAdmin = isAdmin();
public/scripts/extensions/assets/index.js+16 -17
@@ -8,7 +8,9 @@ import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from
88import { deleteExtension, extensionNames, getContext, installExtension, renderExtensionTemplateAsync } from '../../extensions.js';
99import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
1010import { executeSlashCommands } from '../../slash-commands.js';
11+import { accountStorage } from '../../util/AccountStorage.js';
1112import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
13+import { t } from '../../i18n.js';
1214export { MODULE_NAME };
1315
1416const MODULE_NAME = 'assets';
@@ -58,11 +60,11 @@ const KNOWN_TYPES = {
5860 'blip': 'Blip sounds',
5961};
6062
6163async function downloadAssetsList(url) {
6264 updateCurrentAssets().then(async function () {
6365 fetch(url, { cache: 'no-cache' })
6466 .then(response => response.json())
6567 .then(jsonasync =>function(json) {
6668
6769 availableAssets = {};
6870 $('#assets_menu').empty();
@@ -83,10 +85,10 @@ function downloadAssetsList(url) {
8385
8486 $('#assets_type_select').empty();
8587 $('#assets_search').val('');
8688 $('#assets_type_select').append($('<option />', { value: '', text: 't`All'` }));
8789
8890 for (const type of assetTypes) {
8991 const option = $('<option />', { value: type, text: t([KNOWN_TYPES[type] || type]) });
9092 $('#assets_type_select').append(option);
9193 }
9294
@@ -103,11 +105,7 @@ function downloadAssetsList(url) {
103105 assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();
104106
105107 if (assetType == 'extension') {
106- assetTypeMenu.append(`
108+ assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
107- <div class="assets-list-git">
108- To download extensions from this page, you need to have <a href="https://git-scm.com/downloads" target="_blank">Git</a> installed.<br>
109- 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.
110- </div>`);
111109 }
112110
113111 for (const i in availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) {
@@ -183,7 +181,7 @@ function downloadAssetsList(url) {
183181 const displayName = DOMPurify.sanitize(asset['name'] || asset['id']);
184182 const description = DOMPurify.sanitize(asset['description'] || '');
185183 const url = isValidUrl(asset['url']) ? asset['url'] : '';
186184 const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : 't`Preview in browser'`;
187185 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
188186 const toolTag = assetType === 'extension' && asset['tool'];
189187
@@ -194,9 +192,10 @@ function downloadAssetsList(url) {
194192 <b>${displayName}</b>
195193 <a class="asset_preview" href="${url}" target="_blank" title="${title}">
196194 <i class="fa-solid fa-sm ${previewIcon}"></i>
197195 </a>` +
198196 ${(toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> Tool</span>' : ''}+
199- </span>
197+ t`Tool` + '</span>' : '') +
198+ `</span>
200199 <small class="asset-description">
201200 ${description}
202201 </small>
@@ -432,14 +431,14 @@ jQuery(async () => {
432431 connectButton.on('click', async function () {
433432 const url = DOMPurify.sanitize(String(assetsJsonUrl.val()));
434433 const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
435434 const skipConfirm = localStorageaccountStorage.getItem(rememberKey) === 'true';
436435
437436 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>`, {
438437 customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
439438 onClose: popup => {
440439 if (popup.result) {
441440 const rememberValue = popup.inputResults.get('assets-remember');
442441 localStorageaccountStorage.setItem(rememberKey, String(rememberValue));
443442 }
444443 },
445444 });
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/caption/settings.html+7 -1
@@ -10,7 +10,7 @@
1010 <select id="caption_source" class="text_pole">
1111 <option value="local" data-i18n="Local">Local</option>
1212 <option value="multimodal" data-i18n="Multimodal (OpenAI / Anthropic / llama / Google)">Multimodal (OpenAI / Anthropic / llama / Google)</option>
1313 <option value="extras" data-i18n="Extras">Extras (deprecated)</option>
1414 <option value="horde" data-i18n="Horde">Horde</option>
1515 </select>
1616 <div id="caption_multimodal_block" class="flex-container wide100p">
@@ -53,6 +53,12 @@
5353 <option data-type="anthropic" value="claude-3-opus-20240229">claude-3-opus-20240229</option>
5454 <option data-type="anthropic" value="claude-3-sonnet-20240229">claude-3-sonnet-20240229</option>
5555 <option data-type="anthropic" value="claude-3-haiku-20240307">claude-3-haiku-20240307</option>
56+ <option data-type="google" value="gemini-2.0-pro-exp">gemini-2.0-pro-exp</option>
57+ <option data-type="google" value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05</option>
58+ <option data-type="google" value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option>
59+ <option data-type="google" value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>
60+ <option data-type="google" value="gemini-2.0-flash">gemini-2.0-flash</option>
61+ <option data-type="google" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
5662 <option data-type="google" value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
5763 <option data-type="google" value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp</option>
5864 <option data-type="google" value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21</option>
public/scripts/extensions/connection-manager/index.js+4 -0
@@ -30,6 +30,7 @@ const CC_COMMANDS = [
3030 'api-url',
3131 'model',
3232 'proxy',
33+ 'stop-strings',
3334];
3435
3536const TC_COMMANDS = [
@@ -43,6 +44,7 @@ const TC_COMMANDS = [
4344 'context',
4445 'instruct-state',
4546 'tokenizer',
47+ 'stop-strings',
4648];
4749
4850const FANCY_NAMES = {
@@ -57,6 +59,7 @@ const FANCY_NAMES = {
5759 'instruct': 'Instruct Template',
5860 'context': 'Context Template',
5961 'tokenizer': 'Tokenizer',
62+ 'stop-strings': 'Custom Stopping Strings',
6063};
6164
6265/**
@@ -138,6 +141,7 @@ const profilesProvider = () => [
138141 * @property {string} [context] Context Template
139142 * @property {string} [instruct-state] Instruct Mode
140143 * @property {string} [tokenizer] Tokenizer
144+ * @property {string} [stop-strings] Custom Stopping Strings
141145 * @property {string[]} [exclude] Commands to exclude
142146 */
143147
public/scripts/extensions/expressions/index.js+788 -708
@@ -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 => {
355+ if (power_user.reduced_motion) {
356+ element.css('left', currentPosition + 'px');
357+ requestAnimationFrame(() => resolve());
358+ }
359+ else {
297360 element.animate({ left: currentPosition + 'px' }, 500, () => {
298361 resolve();
299362 });
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
447+ if (expressionClone.prop('complete')) {
394448 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);
714+
715+ let label = searchTerm;
716+
717+ /** @type {string?} */
718+ let spriteFile = null;
961719
962- // In Talkinghead mode, don't check for the existence of the sprite
963- // (emotion names are the same as for sprites, but it only needs "talkinghead.png").
964- const currentLastMessage = getLastCharacterMessage();
965- const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
966- let label = spriteId;
967- if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
968720 await validateImages(spriteFolderName);
969721
970- // Fuzzy search for sprite
722+ // Handle reset as a special term and just reset the sprite via expression call
971- const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
723+ if (searchTerm === RESET_SPRITE_LABEL) {
972- const results = fuse.search(spriteId);
724+ await sendExpressionCall(spriteFolderName, label, { force: true });
973- const spriteItem = results[0]?.item;
725+ return lastExpression[spriteFolderName] ?? '';
726+ }
727+
728+ switch (type) {
729+ case 'expression': {
730+ // Fuzzy search for expression
731+ const existingExpressions = getCachedExpressions().map(x => ({ label: x }));
732+ const results = performFuzzySearch('expression-expressions', existingExpressions, [
733+ { name: 'label', weight: 1 },
734+ ], searchTerm);
735+ const matchedExpression = results[0]?.item;
736+ if (!matchedExpression) {
737+ toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`);
738+ return '';
739+ }
974740
975- if (!spriteItem) {
741+ label = matchedExpression.label;
976- console.log('No sprite found for search term ' + spriteId);
742+ break;
743+ }
744+ case 'sprite': {
745+ // Fuzzy search for sprite file
746+ const sprites = spriteCache[spriteFolderName].map(x => x.files).flat();
747+ const results = performFuzzySearch('expression-expressions', sprites, [
748+ { name: 'title', weight: 1 },
749+ { name: 'fileName', weight: 1 },
750+ ], searchTerm);
751+ const matchedSprite = results[0]?.item;
752+ if (!matchedSprite) {
753+ toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`);
977754 return '';
978755 }
979756
980757 label = spriteItemmatchedSprite.labelexpression;
758+ spriteFile = matchedSprite.fileName;
759+ break;
760+ }
761+ default: throw Error('Invalid sprite set type: ' + type);
981762 }
982763
983- const vnMode = isVisualNovelMode();
764+ await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
984- await sendExpressionCall(spriteFolderName, label, true, vnMode);
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 });
@@ -1041,6 +850,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
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
1327-
1171+ .filter(s => s.label === expression)
1328- if (sprite) {
1172+ .map(s => s.files)
13291173 validExpressions.pushflat(sprite);
1330- const listItem = await getListItem(item, sprite.path, 'success', isCustom);
1174+
1175+ if (images.length === 0) {
1176+ const listItem = await getListItem(expression, {
1177+ isCustom,
1178+ images: [getPlaceholderImage(expression, isCustom)],
1179+ });
13311180 $('#image_list').append(listItem);
1181+ continue;
13321182 }
1333- else {
1183+
1334- const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom);
1184+ validExpressions.push({ label: expression, files: images });
1185+
1186+ // Render main = first file, additional = rest
1187+ let listItem = await getListItem(expression, {
1188+ isCustom,
1189+ images,
1190+ });
13351191 $('#image_list').append(listItem);
13361192 }
1337- }
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,38 +1377,88 @@ 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.
1384+ * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same.
1385+ * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one.
1386+ *
1387+ * @param {string} spriteFolderName - The name of the sprite folder
1388+ * @param {string} expression - The expression to find the sprite for
1389+ * @param {object} [options] - Options to select the sprite
1390+ * @param {string} [options.prevExpressionSrc=null] - The source of the previous expression
1391+ * @param {string} [options.overrideSpriteFile=null] - The file name of the sprite to select
1392+ * @returns {ExpressionImage?} - The selected sprite
1393+ */
1394+function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) {
1395+ if (!spriteCache[spriteFolderName]) return null;
1396+ if (expression === RESET_SPRITE_LABEL) return null;
1397+
1398+ // Search for sprites of that expression - or fallback expression sprites if enabled
1399+ let sprite = spriteCache[spriteFolderName].find(x => x.label === expression);
1400+ if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) {
1401+ sprite = spriteCache[spriteFolderName].find(x => x.label === extension_settings.expressions.fallback_expression);
1402+ console.debug('Expression', expression, 'not found. Using fallback expression', extension_settings.expressions.fallback_expression);
1403+ }
1404+ if (!(sprite?.files.length > 0)) return null;
1405+
1406+ let spriteFile = sprite.files[0];
1407+
1408+ // If a specific sprite file should be set, we are looking it up here
1409+ if (overrideSpriteFile) {
1410+ const searched = sprite.files.find(x => x.fileName === overrideSpriteFile);
1411+ if (searched) spriteFile = searched;
1412+ else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`);
1413+ }
1414+ // Else calculate next expression, if multiple are allowed
1415+ else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
1416+ let possibleFiles = sprite.files;
1417+ if (extension_settings.expressions.rerollIfSame) {
1418+ possibleFiles = possibleFiles.filter(x => !prevExpressionSrc || x.imageSrc !== prevExpressionSrc);
1419+ }
1420+ spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)];
1421+ }
1422+
1423+ return spriteFile;
1424+
1425+}
1426+
1427+/**
1428+ * Set the expression of a character.
1429+ * @param {string} spriteFolderName - The name of the character (folder name - can also be a costume override)
1430+ * @param {string} expression - The expression or sprite name to set
1431+ * @param {Object} options - Optional parameters
1432+ * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on
1433+ * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
1434+ * @returns {Promise<void>} A promise that resolves when the expression has been set.
1435+ */
1436+async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) {
1437+ await validateImages(spriteFolderName);
14781438 const img = $('img.expression');
14791439 const prevExpressionSrc = img.attr('src');
14801440 const expressionClone = img.clone();
14811441
1482- const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
1442+ const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile });
1483- console.debug('checking for expression images to show..');
1443+ if (spriteFile) {
1484- if (sprite) {
1485- console.debug('setting expression from character images folder');
1486-
14871444 if (force && isVisualNovelMode()) {
14881445 const context = getContext();
14891446 const group = context.groups.find(x => x.id === context.groupId);
14901447
1491- for (const member of group.members) {
1448+ // If it's a folder, make sure we find the group member based on the actual name
1492- const groupMember = context.characters.find(x => x.avatar === member);
1449+ const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName;
1493-
1494- if (!groupMember) {
1495- continue;
1496- }
14971450
1498- if (groupMember.name == character) {
1451+ const groupMember = group.members
1499- await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);
1452+ .map(member => context.characters.find(x => x.avatar === member))
1453+ .find(groupMember => groupMember && groupMember.name === memberName);
1454+ if (groupMember) {
1455+ await setImage($(`.expression-holder[data-avatar="${groupMember.avatar}"] img`), spriteFile.imageSrc);
15001456 return;
15011457 }
15021458 }
1503- }
1459+
15041460 //only swap expressions when necessary
15051461 if (prevExpressionSrc !== spritespriteFile.pathimageSrc
15061462 && !img.hasClass('expression-animating')) {
15071463 //clone expression
15081464 expressionClone.addClass('expression-clone');
@@ -1510,7 +1466,12 @@ async function setExpression(character, expression, force) {
15101466 //must be made invisible to start because they share the same Z-index
15111467 expressionClone.attr('id', '').css({ opacity: 0 });
15121468 //add new sprite path to clone src
15131469 expressionClone.attr('src', spritespriteFile.pathimageSrc);
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);
15141475 //add invisible clone to html
15151476 expressionClone.appendTo($('#expression-holder'));
15161477
@@ -1552,80 +1513,85 @@ async function setExpression(character, expression, force) {
15521513 expressionHolder.css('min-height', 100);
15531514 });
15541515
1555-
15561516 expressionClone.removeClass('expression-clone');
15571517
15581518 expressionClone.removeClass('default');
15591519 expressionClone.off('error');
15601520 expressionClone.on('error', function (error) {
15611521 console.debug('Expression image error', spritespriteFile.pathimageSrc, error);
15621522 $(this).attr('src', '');
15631523 $(this).off('error');
15641524 if (force && extension_settings.expressions.showDefault) {
1565- setDefault();
1525+ setDefaultEmojiForImage(img, expression);
15661526 }
15671527 });
15681528 }
1529+
1530+ console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
15691531 }
15701532 else {
1571- if (extension_settings.expressions.showDefault) {
1533+ img.attr('data-sprite-folder-name', spriteFolderName);
1572- setDefault();
1573- }
1574- }
15751534
1576- function setDefault() {
1535+ img.off('error');
1577- console.debug('setting default');
1578- const defImgUrl = `/img/default-expressions/${expression}.png`;
1579- //console.log(defImgUrl);
1580- img.attr('src', defImgUrl);
1581- img.addClass('default');
1582- }
1583- document.getElementById('expression-holder').style.display = '';
15841536
1537+ if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) {
1538+ setDefaultEmojiForImage(img, expression);
15851539 } else {
1586- // Set the Talkinghead emotion to the specified expression
1540+ setNoneForImage(img, expression);
1587- // TODO: For now, Talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.
1588- try {
1589- let result = await isTalkingHeadAvailable();
1590- if (result) {
1591- const url = new URL(getApiUrl());
1592- url.pathname = '/api/talkinghead/set_emotion';
1593- await doExtrasFetch(url, {
1594- method: 'POST',
1595- headers: {
1596- 'Content-Type': 'application/json',
1597- },
1598- body: JSON.stringify({ emotion_name: expression }),
1599- });
16001541 }
1601- }
1542+ console.debug('Expression unset - No sprite found', { expression: expression });
1602- catch (error) {
1603- // `set_emotion` is not present in old versions, so let it 404.
16041543 }
16051544
1606- try {
1545+ document.getElementById('expression-holder').style.display = '';
1607- // Find the <img> element with id="expression-image" and class="expression"
1608- const imgElement = document.querySelector('img#expression-image.expression');
1609- //console.log("searching");
1610- if (imgElement && imgElement instanceof HTMLImageElement) {
1611- //console.log("setting value");
1612- imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
1613- }
16141546}
1615- catch (error) {
1547+
1616- //console.log("The fetch failed!");
1548+/**
1549+ * Sets the default expression image for the given image element and expression
1550+ * @param {JQuery<HTMLElement>} img - The image element to set the default expression for
1551+ * @param {string} expression - The expression label to use for the default image
1552+ */
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;
16171557 }
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');
16181565}
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,111 +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 => {
2170+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2171+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2172+ const expressions = getCachedExpressions();
2173+ return expressions.map(expression => {
2174+ const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
21352175 const isCustom = extension_settings.expressions.custom?.includes(expression);
2136- return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D');
2176+ const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
2137- }),
2177+ spriteCount > 1 ? `${spriteCount} sprites` : null;
2178+ return new SlashCommandEnumValue(expression,
2179+ subtitle,
2180+ isCustom ? enumTypes.name : enumTypes.enum,
2181+ isCustom ? 'C' : 'D');
2182+ });
2183+ },
2184+ sprites: () => {
2185+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2186+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2187+ const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? [];
2188+ return sprites.map(x => {
2189+ return new SlashCommandEnumValue(x.title,
2190+ x.title !== x.expression ? x.expression : null,
2191+ x.isCustom ? enumTypes.name : enumTypes.enum,
2192+ x.isCustom ? 'C' : 'D');
2193+ });
2194+ },
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'),
2181- forceEnum: true,
21822275 }),
21832276 ],
21842277 helpString: 'Returns the last set sprite / expression for the named character.',
2185- }));
2186- SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2187- name: 'th',
2188- callback: toggleTalkingHeadCommand,
2189- aliases: ['talkinghead'],
2190- helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
2191- returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
21922278 }));
21932279 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21942280 name: 'classifyexpression-expressionslist',
21952281 aliases: ['expressions'],
2282+ /** @type {(args: {return: string}) => Promise<string>} */
21962283 callback: async (args) => {
2284+ let returnType =
21972285 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2198- // @ts-ignore
2286+ (args.return);
2199- let returnType = args.return;
2200-
2201- // Old legacy return type handling
2202- if (args.format) {
2203- toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning');
2204- const type = String(args?.format).toLowerCase().trim();
2205- switch (type) {
2206- case 'json':
2207- returnType = 'object';
2208- break;
2209- default:
2210- returnType = 'pipe';
2211- break;
2212- }
2213- }
22142287
2215- // Now the actual new return type handling
22162288 const list = await getExpressionsList();
22172289
22182290 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
@@ -2226,22 +2298,13 @@ function migrateSettings() {
22262298 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
22272299 forceEnum: true,
22282300 }),
2229- // TODO remove some day
2230- SlashCommandNamedArgument.fromProps({
2231- name: 'format',
2232- description: '!!! DEPRECATED - use "return" instead !!! The format to return the list in: comma-separated plain text or JSON array. Default is plain text.',
2233- typeList: [ARGUMENT_TYPE.STRING],
2234- enumList: [
2235- new SlashCommandEnumValue('plain', null, enumTypes.enum, ', '),
2236- new SlashCommandEnumValue('json', null, enumTypes.enum, '[]'),
2237- ],
2238- }),
22392301 ],
22402302 returns: 'The comma-separated list of available expressions, including custom expressions.',
22412303 helpString: 'Returns a list of available expressions, including custom expressions.',
22422304 }));
22432305 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
22442306 name: 'expression-classify',
2307+ aliases: ['classify'],
22452308 callback: classifyCallback,
22462309 namedArgumentList: [
22472310 SlashCommandNamedArgument.fromProps({
@@ -2280,11 +2343,13 @@ function migrateSettings() {
22802343 `,
22812344 }));
22822345 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
22832346 name: 'uploadspriteexpression-upload',
2347+ aliases: ['uploadsprite'],
2348+ /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
22842349 callback: async (args, url) => {
22852350 return await uploadSpriteCommand(args, url);
2286- return '';
22872351 },
2352+ returns: 'the resulting sprite name',
22882353 unnamedArgumentList: [
22892354 SlashCommandArgument.fromProps({
22902355 description: 'URL of the image to upload',
@@ -2298,7 +2363,6 @@ function migrateSettings() {
22982363 description: 'Character name or avatar key (default is current character)',
22992364 typeList: [ARGUMENT_TYPE.STRING],
23002365 isRequired: false,
2301- acceptsMultiple: false,
23022366 }),
23032367 SlashCommandNamedArgument.fromProps({
23042368 name: 'label',
@@ -2306,16 +2370,32 @@ function migrateSettings() {
23062370 typeList: [ARGUMENT_TYPE.STRING],
23072371 enumProvider: localEnumProviders.expressions,
23082372 isRequired: true,
2309- acceptsMultiple: false,
23102373 }),
23112374 SlashCommandNamedArgument.fromProps({
23122375 name: 'folder',
23132376 description: 'Override folder to upload into',
23142377 typeList: [ARGUMENT_TYPE.STRING],
23152378 isRequired: false,
2316- 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,
23172385 }),
23182386 ],
2319- 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+ `,
23202400 }));
23212401})();
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}}" />
1719 </div>
20+</div>
21+{{/each}}
public/scripts/extensions/expressions/settings.html+22 -10
@@ -6,24 +6,24 @@
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>
2323 <small data-i18n="Select the API for classifying expressions.">Select the API for classifying expressions.</small>
2424 <select id="expression_api" class="flex1 margin0">
2525 <option value="0" data-i18n="Local">Local</option>
2626 <option value="1" data-i18n="Extras">Extras (deprecated)</option>
2727 <option value="2" data-i18n="Main API">Main API</option>
2828 <option value="3" data-i18n="WebLLM Extension">WebLLM Extension</option>
2929 </select>
@@ -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/gallery/index.js+0 -1
@@ -441,7 +441,6 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
441441 description: 'character name',
442442 typeList: [ARGUMENT_TYPE.STRING],
443443 enumProvider: commonEnumProviders.characters('character'),
444- forceEnum: true,
445444 }),
446445 SlashCommandNamedArgument.fromProps({
447446 name: 'group',
public/scripts/extensions/memory/settings.html+1 -1
@@ -12,7 +12,7 @@
1212 <label for="summary_source" data-i18n="ext_sum_with">Summarize with:</label>
1313 <select id="summary_source">
1414 <option value="main" data-i18n="ext_sum_main_api">Main API</option>
1515 <option value="extras">Extras API (deprecated)</option>
1616 <option value="webllm" data-i18n="ext_sum_webllm">WebLLM Extension</option>
1717 </select><br>
1818
public/scripts/extensions/quick-reply/src/QuickReply.js+9 -8
@@ -10,6 +10,7 @@ import { SlashCommandExecutor } from '../../../slash-commands/SlashCommandExecut
1010import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
1111import { SlashCommandParserError } from '../../../slash-commands/SlashCommandParserError.js';
1212import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
13+import { accountStorage } from '../../../util/AccountStorage.js';
1314import { debounce, delay, getSortableDelay, showFontAwesomePicker } from '../../../utils.js';
1415import { log, quickReplyApi, warn } from '../index.js';
1516import { QuickReplyContextLink } from './QuickReplyContextLink.js';
@@ -544,9 +545,9 @@ export class QuickReply {
544545 this.editorSyntax = messageSyntaxInner;
545546 /**@type {HTMLInputElement}*/
546547 const wrap = dom.querySelector('#qr--modal-wrap');
547548 wrap.checked = JSON.parse(localStorageaccountStorage.getItem('qr--wrap') ?? 'false');
548549 wrap.addEventListener('click', () => {
549550 localStorageaccountStorage.setItem('qr--wrap', JSON.stringify(wrap.checked));
550551 updateWrap();
551552 });
552553 const updateWrap = () => {
@@ -594,27 +595,27 @@ export class QuickReply {
594595 };
595596 /**@type {HTMLInputElement}*/
596597 const tabSize = dom.querySelector('#qr--modal-tabSize');
597598 tabSize.value = JSON.parse(localStorageaccountStorage.getItem('qr--tabSize') ?? '4');
598599 const updateTabSize = () => {
599600 message.style.tabSize = tabSize.value;
600601 messageSyntaxInner.style.tabSize = tabSize.value;
601602 updateScrollDebounced();
602603 };
603604 tabSize.addEventListener('change', () => {
604605 localStorageaccountStorage.setItem('qr--tabSize', JSON.stringify(Number(tabSize.value)));
605606 updateTabSize();
606607 });
607608 /**@type {HTMLInputElement}*/
608609 const executeShortcut = dom.querySelector('#qr--modal-executeShortcut');
609610 executeShortcut.checked = JSON.parse(localStorageaccountStorage.getItem('qr--executeShortcut') ?? 'true');
610611 executeShortcut.addEventListener('click', () => {
611612 localStorageaccountStorage.setItem('qr--executeShortcut', JSON.stringify(executeShortcut.checked));
612613 });
613614 /**@type {HTMLInputElement}*/
614615 const syntax = dom.querySelector('#qr--modal-syntax');
615616 syntax.checked = JSON.parse(localStorageaccountStorage.getItem('qr--syntax') ?? 'true');
616617 syntax.addEventListener('click', () => {
617618 localStorageaccountStorage.setItem('qr--syntax', JSON.stringify(syntax.checked));
618619 updateSyntaxEnabled();
619620 });
620621 if (navigator.keyboard) {
public/scripts/extensions/quick-reply/src/QuickReplySet.js+6 -28
@@ -1,15 +1,14 @@
11import { getRequestHeaders, substituteParams } from '../../../../script.js';
22import { Popup, POPUP_RESULT, POPUP_TYPE } from '../../../popup.js';
33import { executeSlashCommands, executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions } from '../../../slash-commands.js';
4-import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
54import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
65import { debounceAsync, log, warnSlashCommandParser } from '../index../../slash-commands/SlashCommandParser.js';
6+import { debounceAsync, warn } from '../index.js';
77import { QuickReply } from './QuickReply.js';
88
99export class QuickReplySet {
1010 /**@type {QuickReplySet[]}*/ static list = [];
1111
12-
1312 static from(props) {
1413 props.qrList = []; //props.qrList?.map(it=>QuickReply.from(it));
1514 const instance = Object.assign(new this(), props);
@@ -24,9 +23,6 @@ export class QuickReplySet {
2423 return this.list.find(it=>it.name == name);
2524 }
2625
27-
28-
29-
3026 /**@type {string}*/ name;
3127 /**@type {boolean}*/ disableSend = false;
3228 /**@type {boolean}*/ placeBeforeInput = false;
@@ -34,19 +30,12 @@ export class QuickReplySet {
3430 /**@type {string}*/ color = 'transparent';
3531 /**@type {boolean}*/ onlyBorderColor = false;
3632 /**@type {QuickReply[]}*/ qrList = [];
37-
3833 /**@type {number}*/ idIndex = 0;
39-
4034 /**@type {boolean}*/ isDeleted = false;
41-
4235 /**@type {function}*/ save;
43-
4436 /**@type {HTMLElement}*/ dom;
4537 /**@type {HTMLElement}*/ settingsDom;
4638
47-
48-
49-
5039 constructor() {
5140 this.save = debounceAsync(()=>this.performSave(), 200);
5241 }
@@ -55,9 +44,6 @@ export class QuickReplySet {
5544 this.qrList.forEach(qr=>this.hookQuickReply(qr));
5645 }
5746
58-
59-
60-
6147 unrender() {
6248 this.dom?.remove();
6349 this.dom = null;
@@ -100,9 +86,6 @@ export class QuickReplySet {
10086 }
10187 }
10288
103-
104-
105-
10689 renderSettings() {
10790 if (!this.settingsDom) {
10891 this.settingsDom = document.createElement('div'); {
@@ -123,9 +106,6 @@ export class QuickReplySet {
123106 this.settingsDom.append(qr.renderSettings(idx));
124107 }
125108
126-
127-
128-
129109 /**
130110 *
131111 * @param {QuickReply} qr
@@ -138,6 +118,7 @@ export class QuickReplySet {
138118 closure.scope.setMacro('arg::*', '');
139119 return (await closure.execute())?.pipe;
140120 }
121+
141122 /**
142123 *
143124 * @param {QuickReply} qr The QR to execute.
@@ -207,6 +188,7 @@ export class QuickReplySet {
207188 document.querySelector('#send_but').click();
208189 }
209190 }
191+
210192 /**
211193 * @param {QuickReply} qr
212194 * @param {string} [message] - optional altered message to be used
@@ -220,9 +202,6 @@ export class QuickReplySet {
220202 });
221203 }
222204
223-
224-
225-
226205 addQuickReply(data = {}) {
227206 const id = Math.max(this.idIndex, this.qrList.reduce((max,qr)=>Math.max(max,qr.id),0)) + 1;
228207 data.id =
@@ -239,6 +218,7 @@ export class QuickReplySet {
239218 this.save();
240219 return qr;
241220 }
221+
242222 addQuickReplyFromText(qrJson) {
243223 let data;
244224 if (qrJson) {
@@ -371,7 +351,6 @@ export class QuickReplySet {
371351 this.save();
372352 }
373353
374-
375354 toJSON() {
376355 return {
377356 version: 2,
@@ -386,7 +365,6 @@ export class QuickReplySet {
386365 };
387366 }
388367
389-
390368 async performSave() {
391369 const response = await fetch('/api/quick-replies/save', {
392370 method: 'POST',
public/scripts/extensions/quick-reply/src/SlashCommandHandler.js+4 -0
@@ -883,6 +883,10 @@ export class SlashCommandHandler {
883883 }
884884 }
885885 getQuickReply(args) {
886+ if (!args.id && !args.label) {
887+ toastr.error('Please provide a valid id or label.');
888+ return '';
889+ }
886890 try {
887891 return JSON.stringify(this.api.getQrByLabel(args.set, args.id !== undefined ? Number(args.id) : args.label));
888892 } catch (ex) {
public/scripts/extensions/quick-reply/src/ui/SettingsUi.js+1 -1
@@ -346,7 +346,7 @@ export class SettingsUi {
346346 }
347347
348348 async addQrSet() {
349349 const name = await Popup.show.input('Create a new WorldQuick InfoReply Set', 'Enter a name for the new Quick Reply Set:');
350350 if (name && name.length > 0) {
351351 const oldQrs = QuickReplySet.get(name);
352352 if (oldQrs) {
public/scripts/extensions/regex/editor.html+6 -0
@@ -94,6 +94,12 @@
9494 <span data-i18n="World Info">World Info</span>
9595 </label>
9696 </div>
97+ <div data-i18n="[title]ext_regex_reasoning_desc" title="Reasoning block contents. When 'Only Format Prompt' is checked, it will also affect the reasoning contents added to the prompt.">
98+ <label class="checkbox flex-container">
99+ <input type="checkbox" name="replace_position" value="6">
100+ <span data-i18n="Reasoning">Reasoning</span>
101+ </label>
102+ </div>
97103 <div class="flex-container wide100p marginTop5">
98104 <div class="flex1 flex-container flexNoGap">
99105 <small data-i18n="[title]ext_regex_min_depth_desc" title="When applied to prompts or display, only affect messages that are at least N levels deep. 0 = last message, 1 = penultimate message, etc. Only counts WI entries @Depth and usable messages, i.e. not hidden or system.">
public/scripts/extensions/regex/engine.js+2 -1
@@ -20,6 +20,7 @@ const regex_placement = {
2020 SLASH_COMMAND: 3,
2121 // 4 - sendAs (legacy)
2222 WORLD_INFO: 5,
23+ REASONING: 6,
2324};
2425
2526export const substitute_find_regex = {
@@ -94,7 +95,7 @@ function getRegexedString(rawString, placement, { characterOverride, isMarkdown,
9495 // Script applies to Generate and input is Generate
9596 (script.promptOnly && isPrompt) ||
9697 // Script applies to all cases when neither "only"s are true, but there's no need to do it when `isMarkdown`, the as source (chat history) should already be changed beforehand
9798 (!script.markdownOnly && !script.promptOnly && !isMarkdown && !isPrompt)
9899 ) {
99100 if (isEdit && !script.runOnEdit) {
100101 console.debug(`getRegexedString: Skipping script ${script.scriptName} because it does not run on edit`);
public/scripts/extensions/regex/index.js+4 -3
@@ -10,6 +10,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
1010import { download, getFileText, getSortableDelay, uuidv4 } from '../../utils.js';
1111import { regex_placement, runRegexScript, substitute_find_regex } from './engine.js';
1212import { t } from '../../i18n.js';
13+import { accountStorage } from '../../util/AccountStorage.js';
1314
1415/**
1516 * @typedef {object} RegexScript
@@ -18,7 +19,7 @@ import { t } from '../../i18n.js';
1819 * @property {string} replaceString - The replace string
1920 * @property {string[]} trimStrings - The trim strings
2021 * @property {string?} findRegex - The find regex
2122 * @property {stringnumber?} substituteRegex - The substitute regex
2223 */
2324
2425/**
@@ -440,8 +441,8 @@ async function checkEmbeddedRegexScripts() {
440441 if (avatar && !extension_settings.character_allowed_regex.includes(avatar)) {
441442 const checkKey = `AlertRegex_${characters[chid].avatar}`;
442443
443444 if (!localStorageaccountStorage.getItem(checkKey)) {
444445 localStorageaccountStorage.setItem(checkKey, 'true');
445446 const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {});
446447 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Yes' });
447448
public/scripts/extensions/stable-diffusion/index.js+67 -0
@@ -81,6 +81,7 @@ const sources = {
8181 huggingface: 'huggingface',
8282 nanogpt: 'nanogpt',
8383 bfl: 'bfl',
84+ falai: 'falai',
8485};
8586
8687const initiators = {
@@ -1169,6 +1170,10 @@ async function onBflKeyClick() {
11691170 return onApiKeyClick('BFL API Key:', SECRET_KEYS.BFL);
11701171}
11711172
1173+async function onFalaiKeyClick() {
1174+ return onApiKeyClick('FALAI API Key:', SECRET_KEYS.FALAI);
1175+}
1176+
11721177function onBflUpsamplingInput() {
11731178 extension_settings.sd.bfl_upsampling = !!$('#sd_bfl_upsampling').prop('checked');
11741179 saveSettingsDebounced();
@@ -1299,6 +1304,7 @@ async function onModelChange() {
12991304 sources.huggingface,
13001305 sources.nanogpt,
13011306 sources.bfl,
1307+ sources.falai,
13021308 ];
13031309
13041310 if (cloudSources.includes(extension_settings.sd.source)) {
@@ -1707,6 +1713,9 @@ async function loadModels() {
17071713 case sources.bfl:
17081714 models = await loadBflModels();
17091715 break;
1716+ case sources.falai:
1717+ models = await loadFalaiModels();
1718+ break;
17101719 }
17111720
17121721 for (const model of models) {
@@ -1744,6 +1753,21 @@ async function loadBflModels() {
17441753 ];
17451754}
17461755
1756+async function loadFalaiModels() {
1757+ $('#sd_falai_key').toggleClass('success', !!secret_state[SECRET_KEYS.FALAI]);
1758+
1759+ const result = await fetch('/api/sd/falai/models', {
1760+ method: 'POST',
1761+ headers: getRequestHeaders(),
1762+ });
1763+
1764+ if (result.ok) {
1765+ return await result.json();
1766+ }
1767+
1768+ return [];
1769+}
1770+
17471771async function loadPollinationsModels() {
17481772 const result = await fetch('/api/sd/pollinations/models', {
17491773 method: 'POST',
@@ -2081,6 +2105,9 @@ async function loadSchedulers() {
20812105 case sources.bfl:
20822106 schedulers = ['N/A'];
20832107 break;
2108+ case sources.falai:
2109+ schedulers = ['N/A'];
2110+ break;
20842111 }
20852112
20862113 for (const scheduler of schedulers) {
@@ -2735,6 +2762,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
27352762 case sources.bfl:
27362763 result = await generateBflImage(prefixedPrompt, signal);
27372764 break;
2765+ case sources.falai:
2766+ result = await generateFalaiImage(prefixedPrompt, negativePrompt, signal);
2767+ break;
27382768 }
27392769
27402770 if (!result.data) {
@@ -3496,6 +3526,40 @@ async function generateBflImage(prompt, signal) {
34963526 }
34973527}
34983528
3529+/**
3530+ * Generates an image using the FAL.AI API.
3531+ * @param {string} prompt - The main instruction used to guide the image generation.
3532+ * @param {string} negativePrompt - The negative prompt used to guide the image generation.
3533+ * @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
3534+ * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
3535+ */
3536+async function generateFalaiImage(prompt, negativePrompt, signal) {
3537+ const result = await fetch('/api/sd/falai/generate', {
3538+ method: 'POST',
3539+ headers: getRequestHeaders(),
3540+ signal: signal,
3541+ body: JSON.stringify({
3542+ prompt: prompt,
3543+ negative_prompt: negativePrompt,
3544+ model: extension_settings.sd.model,
3545+ steps: clamp(extension_settings.sd.steps, 1, 50),
3546+ guidance: clamp(extension_settings.sd.scale, 1.5, 5),
3547+ width: clamp(extension_settings.sd.width, 256, 1440),
3548+ height: clamp(extension_settings.sd.height, 256, 1440),
3549+ seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
3550+ }),
3551+ });
3552+
3553+ if (result.ok) {
3554+ const data = await result.json();
3555+ return { format: 'jpg', data: data.image };
3556+ } else {
3557+ const text = await result.text();
3558+ console.log(text);
3559+ throw new Error(text);
3560+ }
3561+}
3562+
34993563async function onComfyOpenWorkflowEditorClick() {
35003564 let workflow = await (await fetch('/api/sd/comfy/workflow', {
35013565 method: 'POST',
@@ -3782,6 +3846,8 @@ function isValidState() {
37823846 return secret_state[SECRET_KEYS.NANOGPT];
37833847 case sources.bfl:
37843848 return secret_state[SECRET_KEYS.BFL];
3849+ case sources.falai:
3850+ return secret_state[SECRET_KEYS.FALAI];
37853851 }
37863852}
37873853
@@ -4443,6 +4509,7 @@ jQuery(async () => {
44434509 $('#sd_function_tool').on('input', onFunctionToolInput);
44444510 $('#sd_bfl_key').on('click', onBflKeyClick);
44454511 $('#sd_bfl_upsampling').on('input', onBflUpsamplingInput);
4512+ $('#sd_falai_key').on('click', onFalaiKeyClick);
44464513
44474514 if (!CSS.supports('field-sizing', 'content')) {
44484515 $('.sd_settings .inline-drawer-toggle').on('click', function () {
public/scripts/extensions/stable-diffusion/settings.html+16 -1
@@ -41,7 +41,8 @@
4141 <option value="blockentropy">Block Entropy</option>
4242 <option value="comfy">ComfyUI</option>
4343 <option value="drawthings">DrawThings HTTP API</option>
4444 <option value="extras">Extras API (local / remotedeprecated)</option>
45+ <option value="falai">FAL.AI</option>
4546 <option value="huggingface">HuggingFace Inference API (serverless)</option>
4647 <option value="nanogpt">NanoGPT</option>
4748 <option value="novel">NovelAI Diffusion</option>
@@ -256,6 +257,20 @@
256257 </label>
257258 </div>
258259
260+ <div data-sd-source="falai">
261+ <div class="flex-container flexnowrap alignItemsBaseline marginBot5">
262+ <a href="https://fal.ai/dashboard" target="_blank" rel="noopener noreferrer">
263+ <strong data-i18n="API Key">API Key</strong>
264+ <i class="fa-solid fa-share-from-square"></i>
265+ </a>
266+ <span class="expander"></span>
267+ <div id="sd_falai_key" class="menu_button menu_button_icon">
268+ <i class="fa-fw fa-solid fa-key"></i>
269+ <span data-i18n="Click to set">Click to set</span>
270+ </div>
271+ </div>
272+ </div>
273+
259274 <div class="flex-container">
260275 <div class="flex1">
261276 <label for="sd_model" data-i18n="Model">Model</label>
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/alltalk.js+10 -6
@@ -388,7 +388,7 @@ class AllTalkTtsProvider {
388388 }
389389
390390 async fetchRvcVoiceObjects() {
391391 if (this.settings.server_version == 'v2v1') {
392392 console.log('Skipping RVC voices fetch for V1 server');
393393 return [];
394394 }
@@ -1031,14 +1031,18 @@ class AllTalkTtsProvider {
10311031 console.error('fetchTtsGeneration Error Response Text:', errorText);
10321032 throw new Error(`HTTP ${response.status}: ${errorText}`);
10331033 }
1034+
10341035 const data = await response.json();
10351036
1036- // Handle V1/V2 URL differences
1037+ // V1 returns a complete URL, V2 returns a relative path
10371038 const outputUrl =if (this.settings.server_version === 'v1') {
1038- ? data.output_file_url // V1 returns full URL
1039+ // V1: Use the complete URL directly from the response
1039- : `${this.settings.provider_endpoint}${data.output_file_url}`; // V2 returns relative path
1040+ return data.output_file_url;
1041+ } else {
1042+ // V2: Combine the endpoint with the relative path
1043+ return `${this.settings.provider_endpoint}${data.output_file_url}`;
1044+ }
10401045
1041- return outputUrl;
10421046 } catch (error) {
10431047 console.error('[fetchTtsGeneration] Exception caught:', error);
10441048 throw error;
public/scripts/extensions/tts/index.js+47 -30
@@ -27,13 +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;
32+const wrapper = new ModuleWorkerWrapper(moduleWorker);
3333
3434let voiceMapEntries = [];
3535let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
36-let talkingHeadState = false;
3736let lastChatId = null;
3837let lastMessage = null;
3938let lastMessageHash = null;
@@ -120,7 +119,7 @@ async function onNarrateOneMessage() {
120119 }
121120
122121 resetTtsPlayback();
123122 ttsJobQueue.pushprocessAndQueueTtsMessage(message);
124123 moduleWorker();
125124}
126125
@@ -147,7 +146,7 @@ async function onNarrateText(args, text) {
147146 }
148147
149148 resetTtsPlayback();
150149 ttsJobQueue.pushprocessAndQueueTtsMessage({ mes: text, name: name });
151150 await moduleWorker();
152151
153152 // Return back to the chat voices
@@ -165,27 +164,6 @@ async function moduleWorker() {
165164 updateUiAudioPlayState();
166165}
167166
168-function talkingAnimation(switchValue) {
169- if (!modules.includes('talkinghead')) {
170- console.debug('Talking Animation module not loaded');
171- return;
172- }
173-
174- const apiUrl = getApiUrl();
175- const animationType = switchValue ? 'start' : 'stop';
176-
177- if (switchValue !== talkingHeadState) {
178- try {
179- console.log(animationType + ' Talking Animation');
180- doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`);
181- talkingHeadState = switchValue;
182- } catch (error) {
183- // Handle the error here or simply ignore it to prevent logging
184- }
185- }
186- updateUiAudioPlayState();
187-}
188-
189167function resetTtsPlayback() {
190168 // Stop system TTS utterance
191169 cancelTtsPlay();
@@ -220,6 +198,36 @@ function isTtsProcessing() {
220198 return processing;
221199}
222200
201+/**
202+ * Splits a message into lines and adds each non-empty line to the TTS job queue.
203+ * @param {Object} message - The message object to be processed.
204+ * @param {string} message.mes - The text of the message to be split into lines.
205+ * @param {string} message.name - The name associated with the message.
206+ * @returns {void}
207+ */
208+function processAndQueueTtsMessage(message) {
209+ if (!extension_settings.tts.narrate_by_paragraphs) {
210+ ttsJobQueue.push(message);
211+ return;
212+ }
213+
214+ const lines = message.mes.split('\n');
215+
216+ for (let i = 0; i < lines.length; i++) {
217+ const line = lines[i];
218+
219+ if (line.length === 0) {
220+ continue;
221+ }
222+
223+ ttsJobQueue.push(
224+ Object.assign({}, message, {
225+ mes: line,
226+ }),
227+ );
228+ }
229+}
230+
223231function debugTtsPlayback() {
224232 console.log(JSON.stringify(
225233 {
@@ -347,10 +355,9 @@ function onAudioControlClicked() {
347355 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
348356 if (!audioElement.paused || isTtsProcessing()) {
349357 resetTtsPlayback();
350- talkingAnimation(false);
351358 } else {
352359 // Default play behavior if not processing or playing is to play the last message.
353360 ttsJobQueue.pushprocessAndQueueTtsMessage(context.chat[context.chat.length - 1]);
354361 }
355362 updateUiAudioPlayState();
356363}
@@ -374,8 +381,8 @@ function addAudioControl() {
374381function completeCurrentAudioJob() {
375382 audioQueueProcessorReady = true;
376383 currentAudioJob = null;
377- talkingAnimation(false); //stop lip animation
378384 // updateUiPlayState();
385+ wrapper.update();
379386}
380387
381388/**
@@ -404,7 +411,6 @@ async function processAudioJobQueue() {
404411 audioQueueProcessorReady = false;
405412 currentAudioJob = audioJobQueue.shift();
406413 playAudioData(currentAudioJob);
407- talkingAnimation(true);
408414 } catch (error) {
409415 toastr.error(error.toString());
410416 console.error(error);
@@ -569,6 +575,7 @@ function loadSettings() {
569575 $('#tts_narrate_quoted').prop('checked', extension_settings.tts.narrate_quoted_only);
570576 $('#tts_auto_generation').prop('checked', extension_settings.tts.auto_generation);
571577 $('#tts_periodic_auto_generation').prop('checked', extension_settings.tts.periodic_auto_generation);
578+ $('#tts_narrate_by_paragraphs').prop('checked', extension_settings.tts.narrate_by_paragraphs);
572579 $('#tts_narrate_translated_only').prop('checked', extension_settings.tts.narrate_translated_only);
573580 $('#tts_narrate_user').prop('checked', extension_settings.tts.narrate_user);
574581 $('#tts_pass_asterisks').prop('checked', extension_settings.tts.pass_asterisks);
@@ -638,6 +645,11 @@ function onPeriodicAutoGenerationClick() {
638645 saveSettingsDebounced();
639646}
640647
648+function onNarrateByParagraphsClick() {
649+ extension_settings.tts.narrate_by_paragraphs = !!$('#tts_narrate_by_paragraphs').prop('checked');
650+ saveSettingsDebounced();
651+}
652+
641653
642654function onNarrateDialoguesClick() {
643655 extension_settings.tts.narrate_dialogues_only = !!$('#tts_narrate_dialogues').prop('checked');
@@ -816,7 +828,12 @@ async function onMessageEvent(messageId, lastCharIndex) {
816828 lastChatId = context.chatId;
817829
818830 console.debug(`Adding message from ${message.name} for TTS processing: "${message.mes}"`);
831+
832+ if (extension_settings.tts.periodic_auto_generation) {
819833 ttsJobQueue.push(message);
834+ } else {
835+ processAndQueueTtsMessage(message);
836+ }
820837}
821838
822839async function onMessageDeleted() {
@@ -1156,6 +1173,7 @@ jQuery(async function () {
11561173 $('#tts_pass_asterisks').on('click', onPassAsterisksClick);
11571174 $('#tts_auto_generation').on('click', onAutoGenerationClick);
11581175 $('#tts_periodic_auto_generation').on('click', onPeriodicAutoGenerationClick);
1176+ $('#tts_narrate_by_paragraphs').on('click', onNarrateByParagraphsClick);
11591177 $('#tts_narrate_user').on('click', onNarrateUserClick);
11601178
11611179 $('#playback_rate').on('input', function () {
@@ -1177,7 +1195,6 @@ jQuery(async function () {
11771195 loadSettings(); // Depends on Extension Controls and loadTtsProvider
11781196 loadTtsProvider(extension_settings.tts.currentProvider); // No dependencies
11791197 addAudioControl(); // Depends on Extension Controls
1180- const wrapper = new ModuleWorkerWrapper(moduleWorker);
11811198 setInterval(wrapper.update.bind(wrapper), UPDATE_INTERVAL); // Init depends on all the things
11821199 eventSource.on(event_types.MESSAGE_SWIPED, resetTtsPlayback);
11831200 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
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/settings.html+4 -0
@@ -30,6 +30,10 @@
3030 <input type="checkbox" id="tts_periodic_auto_generation">
3131 <small data-i18n="Narrate by paragraphs (when streaming)">Narrate by paragraphs (when streaming)</small>
3232 </label>
33+ <label class="checkbox_label" for="tts_narrate_by_paragraphs">
34+ <input type="checkbox" id="tts_narrate_by_paragraphs">
35+ <small data-i18n="Narrate by paragraphs (when not streaming)">Narrate by paragraphs (when not streaming)</small>
36+ </label>
3337 <label class="checkbox_label" for="tts_narrate_quoted">
3438 <input type="checkbox" id="tts_narrate_quoted">
3539 <small data-i18n="Only narrate quotes">Only narrate "quotes"</small>
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+60 -68
@@ -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,74 +746,65 @@ async function getQueryText(chat, initiator) {
746746}
747747
748748/**
749749 * Gets thecommon savedbody hashesparameters for avector collectionrequests.
750750 * @paramreturns {stringobject} collectionId
751-* @returns {Promise<number[]>} Saved hashes
752751 */
753752async function getSavedHashesgetVectorsRequestBody(collectionId) {
754- const response = await fetch('/api/vector/list', {
753+ const body = {};
755- method: 'POST',
756- headers: getVectorHeaders(),
757- body: JSON.stringify({
758- collectionId: collectionId,
759- source: settings.source,
760- }),
761- });
762-
763- if (!response.ok) {
764- throw new Error(`Failed to get saved hashes for collection ${collectionId}`);
765- }
766-
767- const hashes = await response.json();
768- return hashes;
769-}
770-
771-function getVectorHeaders() {
772- const headers = getRequestHeaders();
773754 switch (settings.source) {
774755 case 'extras':
775- Object.assign(headers, {
756+ body.extrasUrl = extension_settings.apiUrl;
776- 'X-Extras-Url': extension_settings.apiUrl,
757+ body.extrasKey = extension_settings.apiKey;
777- 'X-Extras-Key': extension_settings.apiKey,
778- });
779758 break;
780759 case 'togetherai':
781- Object.assign(headers, {
760+ body.model = extension_settings.vectors.togetherai_model;
782- 'X-Togetherai-Model': extension_settings.vectors.togetherai_model,
783- });
784761 break;
785762 case 'openai':
786- Object.assign(headers, {
763+ body.model = extension_settings.vectors.openai_model;
787- 'X-OpenAI-Model': extension_settings.vectors.openai_model,
788- });
789764 break;
790765 case 'cohere':
791- Object.assign(headers, {
766+ body.model = extension_settings.vectors.cohere_model;
792- 'X-Cohere-Model': extension_settings.vectors.cohere_model,
793- });
794767 break;
795768 case 'ollama':
796- Object.assign(headers, {
769+ body.model = extension_settings.vectors.ollama_model;
797- 'X-Ollama-Model': extension_settings.vectors.ollama_model,
770+ body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.OLLAMA];
798- 'X-Ollama-URL': textgenerationwebui_settings.server_urls[textgen_types.OLLAMA],
771+ body.keep = !!extension_settings.vectors.ollama_keep;
799- 'X-Ollama-Keep': !!extension_settings.vectors.ollama_keep,
800- });
801772 break;
802773 case 'llamacpp':
803- Object.assign(headers, {
774+ body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP];
804- 'X-LlamaCpp-URL': textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP],
805- });
806775 break;
807776 case 'vllm':
808- Object.assign(headers, {
777+ body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.VLLM];
809- 'X-Vllm-URL': textgenerationwebui_settings.server_urls[textgen_types.VLLM],
778+ body.model = extension_settings.vectors.vllm_model;
810- 'X-Vllm-Model': extension_settings.vectors.vllm_model,
811- });
812779 break;
813780 default:
814781 break;
815782 }
816783 return headersbody;
784+}
785+
786+/**
787+ * Gets the saved hashes for a collection
788+* @param {string} collectionId
789+* @returns {Promise<number[]>} Saved hashes
790+*/
791+async function getSavedHashes(collectionId) {
792+ const response = await fetch('/api/vector/list', {
793+ method: 'POST',
794+ headers: getRequestHeaders(),
795+ body: JSON.stringify({
796+ ...getVectorsRequestBody(),
797+ collectionId: collectionId,
798+ source: settings.source,
799+ }),
800+ });
801+
802+ if (!response.ok) {
803+ throw new Error(`Failed to get saved hashes for collection ${collectionId}`);
804+ }
805+
806+ const hashes = await response.json();
807+ return hashes;
817808}
818809
819810/**
@@ -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) {
@@ -1638,14 +1632,12 @@ jQuery(async () => {
16381632 }
16391633 return textResult;
16401634 };
1641-
16421635 if (args.return === 'chunks') {
16431636 return getChunksText();
16441637 }
16451638
16461639 // @ts-ignore
16471640 return slashCommandReturnHelper.doReturn(args.return ?? 'object', urls, { objectToStringFunc: list => list.join('\n') });
1648-
16491641 },
16501642 aliases: ['databank-search', 'data-bank-search'],
16511643 helpString: 'Search the Data Bank for a specific query using vector similarity. Returns a list of file URLs with the most relevant content.',
@@ -1660,10 +1652,10 @@ jQuery(async () => {
16601652 defaultValue: 'object',
16611653 enumList: [
16621654 new SlashCommandEnumValue('chunks', 'Return the actual content chunks', enumTypes.enum, '{}'),
16631655 ...slashCommandReturnHelper.enumList({ allowObject: true }),
16641656 ],
16651657 forceEnum: true,
16661658 }),
16671659 ],
16681660 unnamedArgumentList: [
16691661 new SlashCommandArgument('Query to search by.', ARGUMENT_TYPE.STRING, true, false),
public/scripts/extensions/vectors/settings.html+1 -1
@@ -11,7 +11,7 @@
1111 </label>
1212 <select id="vectors_source" class="text_pole">
1313 <option value="cohere">Cohere</option>
1414 <option value="extras">Extras (deprecated)</option>
1515 <option value="palm">Google AI Studio</option>
1616 <option value="llamacpp">llama.cpp</option>
1717 <option value="transformers" data-i18n="Local (Transformers)">Local (Transformers)</option>
public/scripts/f-localStorage.js+15 -0
@@ -1,18 +1,30 @@
11////////////////// LOCAL STORAGE HANDLING /////////////////////
22
3+/**
4+ * @deprecated THIS FUNCTION IS OBSOLETE. DO NOT USE
5+ */
36export function SaveLocal(target, val) {
47 localStorage.setItem(target, val);
58 console.debug('SaveLocal -- ' + target + ' : ' + val);
69}
10+/**
11+ * @deprecated THIS FUNCTION IS OBSOLETE. DO NOT USE
12+ */
713export function LoadLocal(target) {
814 console.debug('LoadLocal -- ' + target);
915 return localStorage.getItem(target);
1016
1117}
18+/**
19+ * @deprecated THIS FUNCTION IS OBSOLETE. DO NOT USE
20+ */
1221export function LoadLocalBool(target) {
1322 let result = localStorage.getItem(target) === 'true';
1423 return result;
1524}
25+/**
26+ * @deprecated THIS FUNCTION IS OBSOLETE. DO NOT USE
27+ */
1628export function CheckLocal() {
1729 console.log('----------local storage---------');
1830 var i;
@@ -22,6 +34,9 @@ export function CheckLocal() {
2234 console.log('------------------------------');
2335}
2436
37+/**
38+ * @deprecated THIS FUNCTION IS OBSOLETE. DO NOT USE
39+ */
2540export function ClearLocal() { localStorage.clear(); console.log('Removed All Local Storage'); }
2641
2742/////////////////////////////////////////////////////////////////////////
public/scripts/group-chats.js+37 -39
@@ -78,6 +78,7 @@ import { FILTER_TYPES, FilterHelper } from './filters.js';
7878import { isExternalMediaAllowed } from './chats.js';
7979import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
8080import { t } from './i18n.js';
81+import { accountStorage } from './util/AccountStorage.js';
8182
8283export {
8384 selected_group,
@@ -292,10 +293,11 @@ export function getGroupNames() {
292293
293294/**
294295 * Finds the character ID for a group member.
295296 * @param {number|string} arg 0-based member index or character name
296- * @returns {number} 0-based character ID
297+ * @param {Boolean} full Whether to return a key-value object containing extra data
298+ * @returns {number|Object} 0-based character ID or key-value object if full is true
297299 */
298300export function findGroupMemberId(arg, full = false) {
299301 arg = arg?.trim();
300302
301303 if (!arg) {
@@ -311,15 +313,19 @@ export function findGroupMemberId(arg) {
311313 }
312314
313315 const index = parseInt(arg);
314316 const searchByNamesearchByString = isNaN(index);
315317
316318 if (searchByNamesearchByString) {
317- const memberNames = group.members.map(x => ({ name: characters.find(y => y.avatar === x)?.name, index: characters.findIndex(y => y.avatar === x) }));
319+ const memberNames = group.members.map(x => ({
318- const fuse = new Fuse(memberNames, { keys: ['name'] });
320+ avatar: x,
321+ name: characters.find(y => y.avatar === x)?.name,
322+ index: characters.findIndex(y => y.avatar === x),
323+ }));
324+ const fuse = new Fuse(memberNames, { keys: ['avatar', 'name'] });
319325 const result = fuse.search(arg);
320326
321327 if (!result.length) {
322328 console.warn(`WARN: No group member found withusing namestring ${arg}`);
323329 return;
324330 }
325331
@@ -330,9 +336,11 @@ export function findGroupMemberId(arg) {
330336 return;
331337 }
332338
333339 console.log(`TriggeringTargeting group member ${chid} (${arg}) from search result`, result[0]);
334- return chid;
340+
335- } else {
341+ return !full ? chid : { ...{ id: chid }, ...result[0].item };
342+ }
343+ else {
336344 const memberAvatar = group.members[index];
337345
338346 if (memberAvatar === undefined) {
@@ -347,8 +355,14 @@ export function findGroupMemberId(arg) {
347355 return;
348356 }
349357
350358 console.log(`TriggeringTargeting group member ${memberAvatar} at index ${index}`);
351- return chid;
359+
360+ return !full ? chid : {
361+ id: chid,
362+ avatar: memberAvatar,
363+ name: characters.find(y => y.avatar === memberAvatar)?.name,
364+ index: index,
365+ };
352366 }
353367}
354368
@@ -805,7 +819,6 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
805819
806820 /** @type {any} Caution: JS war crimes ahead */
807821 let textResult = '';
808- let typingIndicator = $('#chat .typing_indicator');
809822 const group = groups.find((x) => x.id === selected_group);
810823
811824 if (!group || !Array.isArray(group.members) || !group.members.length) {
@@ -821,14 +834,6 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
821834 setCharacterId(undefined);
822835 const userInput = String($('#send_textarea').val());
823836
824- if (typingIndicator.length === 0 && !isStreamingEnabled()) {
825- typingIndicator = $(
826- '#typing_indicator_template .typing_indicator',
827- ).clone();
828- typingIndicator.hide();
829- $('#chat').append(typingIndicator);
830- }
831-
832837 // id of this specific batch for regeneration purposes
833838 group_generation_id = Date.now();
834839 const lastMessage = chat[chat.length - 1];
@@ -906,14 +911,6 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
906911 }
907912 await eventSource.emit(event_types.GROUP_MEMBER_DRAFTED, chId);
908913
909- if (type !== 'swipe' && type !== 'impersonate' && !isStreamingEnabled()) {
910- // update indicator and scroll down
911- typingIndicator
912- .find('.typing_indicator_name')
913- .text(characters[chId].name);
914- typingIndicator.show();
915- }
916-
917914 // Wait for generation to finish
918915 textResult = await Generate(generateType, { automatic_trigger: by_auto_mode, ...(params || {}) });
919916 let messageChunk = textResult?.messageChunk;
@@ -930,8 +927,6 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
930927 }
931928 }
932929 } finally {
933- typingIndicator.hide();
934-
935930 is_group_generating = false;
936931 setSendButtonState(false);
937932 setCharacterId(undefined);
@@ -1315,10 +1310,10 @@ function printGroupCandidates() {
13151310 formatNavigator: PAGINATION_TEMPLATE,
13161311 showNavigator: true,
13171312 showSizeChanger: true,
13181313 pageSize: Number(localStorageaccountStorage.getItem(storageKey)) || 5,
13191314 sizeChangerOptions: [5, 10, 25, 50, 100, 200, 500, 1000],
13201315 afterSizeSelectorChange: function (e) {
13211316 localStorageaccountStorage.setItem(storageKey, e.target.value);
13221317 },
13231318 callback: function (data) {
13241319 $('#rm_group_add_members').empty();
@@ -1342,10 +1337,10 @@ function printGroupMembers() {
13421337 formatNavigator: PAGINATION_TEMPLATE,
13431338 showNavigator: true,
13441339 showSizeChanger: true,
13451340 pageSize: Number(localStorageaccountStorage.getItem(storageKey)) || 5,
13461341 sizeChangerOptions: [5, 10, 25, 50, 100, 200, 500, 1000],
13471342 afterSizeSelectorChange: function (e) {
13481343 localStorageaccountStorage.setItem(storageKey, e.target.value);
13491344 },
13501345 callback: function (data) {
13511346 $('.rm_group_members').empty();
@@ -1669,12 +1664,12 @@ function updateFavButtonState(state) {
16691664export async function openGroupById(groupId) {
16701665 if (isChatSaving) {
16711666 toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`);
16721667 return false;
16731668 }
16741669
16751670 if (!groups.find(x => x.id === groupId)) {
16761671 console.log('Group not found', groupId);
16771672 return false;
16781673 }
16791674
16801675 if (!is_send_press && !is_group_generating) {
@@ -1691,8 +1686,11 @@ export async function openGroupById(groupId) {
16911686 updateChatMetadata({}, true);
16921687 chat.length = 0;
16931688 await getGroupChat(groupId);
1689+ return true;
16941690 }
16951691 }
1692+
1693+ return false;
16961694}
16971695
16981696function openCharacterDefinition(characterSelect) {
public/scripts/loader.js+27 -6
@@ -27,21 +27,42 @@ export async function hideLoader() {
2727 }
2828
2929 return new Promise((resolve) => {
30- // Spinner blurs/fades out
30+ const spinner = $('#load-spinner');
31- $('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
31+ if (!spinner.length) {
32+ console.warn('Spinner element not found, skipping animation');
33+ cleanup();
34+ return;
35+ }
36+
37+ // Check if transitions are enabled
38+ const transitionDuration = spinner[0] ? getComputedStyle(spinner[0]).transitionDuration : '0s';
39+ const hasTransitions = parseFloat(transitionDuration) > 0;
40+
41+ if (hasTransitions) {
42+ Promise.race([
43+ new Promise((r) => setTimeout(r, 500)), // Fallback timeout
44+ new Promise((r) => spinner.one('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', r)),
45+ ]).finally(cleanup);
46+ } else {
47+ cleanup();
48+ }
49+
50+ function cleanup() {
3251 $('#loader').remove();
3352 // Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
3453 // If it's present, we remove it once and then it's gone.
3554 yoinkPreloader();
3655
3756 loaderPopup.complete(POPUP_RESULT.AFFIRMATIVE).then(() => {
57+ .catch((err) => console.error('Error completing loaderPopup:', err))
58+ .finally(() => {
3859 loaderPopup = null;
3960 resolve();
4061 });
4162 });
4263
43- $('#load-spinner')
64+ // Apply the styles
4465 spinner.css({
4566 'filter': 'blur(15px)',
4667 'opacity': '0',
4768 });
public/scripts/openai.js+96 -62
@@ -73,6 +73,7 @@ import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js
7373import { Popup, POPUP_RESULT } from './popup.js';
7474import { t } from './i18n.js';
7575import { ToolManager } from './tool-calling.js';
76+import { accountStorage } from './util/AccountStorage.js';
7677
7778export {
7879 openai_messages_count,
@@ -82,7 +83,6 @@ export {
8283 setOpenAIMessageExamples,
8384 setupChatCompletionPromptManager,
8485 sendOpenAIRequest,
85- getChatCompletionModel,
8686 TokenHandler,
8787 IdentifierNotFoundError,
8888 Message,
@@ -258,8 +258,8 @@ const default_settings = {
258258 ai21_model: 'jamba-1.5-large',
259259 mistralai_model: 'mistral-large-latest',
260260 cohere_model: 'command-r-plus',
261261 perplexity_model: 'llama-3.1-70bsonar-instructpro',
262262 groq_model: 'llama-3.13-70b-versatile',
263263 nanogpt_model: 'gpt-4o-mini',
264264 zerooneai_model: 'yi-large',
265265 blockentropy_model: 'be-70b-base-llama3.1',
@@ -298,7 +298,8 @@ const default_settings = {
298298 names_behavior: character_names_behavior.DEFAULT,
299299 continue_postfix: continue_postfix_types.SPACE,
300300 custom_prompt_post_processing: custom_prompt_post_processing_types.NONE,
301301 show_thoughts: falsetrue,
302+ reasoning_effort: 'medium',
302303 seed: -1,
303304 n: 1,
304305};
@@ -337,7 +338,7 @@ const oai_settings = {
337338 ai21_model: 'jamba-1.5-large',
338339 mistralai_model: 'mistral-large-latest',
339340 cohere_model: 'command-r-plus',
340341 perplexity_model: 'llama-3.1-70bsonar-instructpro',
341342 groq_model: 'llama-3.1-70b-versatile',
342343 nanogpt_model: 'gpt-4o-mini',
343344 zerooneai_model: 'yi-large',
@@ -377,7 +378,8 @@ const oai_settings = {
377378 names_behavior: character_names_behavior.DEFAULT,
378379 continue_postfix: continue_postfix_types.SPACE,
379380 custom_prompt_post_processing: custom_prompt_post_processing_types.NONE,
380381 show_thoughts: falsetrue,
382+ reasoning_effort: 'medium',
381383 seed: -1,
382384 n: 1,
383385};
@@ -412,7 +414,7 @@ async function validateReverseProxy() {
412414 throw err;
413415 }
414416 const rememberKey = `Proxy_SkipConfirm_${getStringHash(oai_settings.reverse_proxy)}`;
415417 const skipConfirm = localStorageaccountStorage.getItem(rememberKey) === 'true';
416418
417419 const confirmation = skipConfirm || await Popup.show.confirm(t`Connecting To Proxy`, await renderTemplateAsync('proxyConnectionWarning', { proxyURL: DOMPurify.sanitize(oai_settings.reverse_proxy) }));
418420
@@ -423,7 +425,7 @@ async function validateReverseProxy() {
423425 throw new Error('Proxy connection denied.');
424426 }
425427
426428 localStorageaccountStorage.setItem(rememberKey, String(true));
427429}
428430
429431/**
@@ -1443,9 +1445,7 @@ async function sendWindowAIRequest(messages, signal, stream) {
14431445 }
14441446
14451447 const onStreamResult = (res, err) => {
14461448 if (err) {return;
1447- return;
1448- }
14491449
14501450 const thisContent = res?.message?.content;
14511451
@@ -1497,7 +1497,7 @@ async function sendWindowAIRequest(messages, signal, stream) {
14971497 }
14981498}
14991499
15001500export function getChatCompletionModel() {
15011501 switch (oai_settings.chat_completion_source) {
15021502 case chat_completion_sources.CLAUDE:
15031503 return oai_settings.claude_model;
@@ -1869,7 +1869,7 @@ async function sendOpenAIRequest(type, messages, signal) {
18691869 const isQuiet = type === 'quiet';
18701870 const isImpersonate = type === 'impersonate';
18711871 const isContinue = type === 'continue';
18721872 const stream = oai_settings.stream_openai && !isQuiet && !isScale && !(isGoogleisOAI && oai_settings.google_model.includes(['bisono1-2024-12-17')) &&, !'o1'].includes(isOAI && oai_settings.openai_model.startsWith('o1-'));
18731873 const useLogprobs = !!power_user.request_token_probabilities;
18741874 const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isCustom);
18751875
@@ -1913,9 +1913,14 @@ async function sendOpenAIRequest(type, messages, signal) {
19131913 'user_name': name1,
19141914 'char_name': name2,
19151915 'group_names': getGroupNames(),
19161916 'show_thoughtsinclude_reasoning': Boolean(oai_settings.show_thoughts),
1917+ 'reasoning_effort': String(oai_settings.reasoning_effort),
19171918 };
19181919
1920+ if (!canMultiSwipe && ToolManager.canPerformToolCalls(type)) {
1921+ await ToolManager.registerFunctionToolsOpenAI(generate_data);
1922+ }
1923+
19191924 // Empty array will produce a validation error
19201925 if (!Array.isArray(generate_data.stop) || !generate_data.stop.length) {
19211926 delete generate_data.stop;
@@ -2039,6 +2044,8 @@ async function sendOpenAIRequest(type, messages, signal) {
20392044 delete generate_data.top_logprobs;
20402045 delete generate_data.logprobs;
20412046 delete generate_data.logit_bias;
2047+ delete generate_data.tools;
2048+ delete generate_data.tool_choice;
20422049 }
20432050 }
20442051
@@ -2046,11 +2053,7 @@ async function sendOpenAIRequest(type, messages, signal) {
20462053 generate_data['seed'] = oai_settings.seed;
20472054 }
20482055
2049- if (!canMultiSwipe && ToolManager.canPerformToolCalls(type)) {
2056+ if (isOAI && (oai_settings.openai_model.startsWith('o1') || oai_settings.openai_model.startsWith('o3'))) {
2050- await ToolManager.registerFunctionToolsOpenAI(generate_data);
2051- }
2052-
2053- if (isOAI && oai_settings.openai_model.startsWith('o1-')) {
20542057 generate_data.messages.forEach((msg) => {
20552058 if (msg.role === 'system') {
20562059 msg.role = 'user';
@@ -2058,7 +2061,6 @@ async function sendOpenAIRequest(type, messages, signal) {
20582061 });
20592062 generate_data.max_completion_tokens = generate_data.max_tokens;
20602063 delete generate_data.max_tokens;
2061- delete generate_data.stream;
20622064 delete generate_data.logprobs;
20632065 delete generate_data.top_logprobs;
20642066 delete generate_data.n;
@@ -2069,8 +2071,7 @@ async function sendOpenAIRequest(type, messages, signal) {
20692071 delete generate_data.tools;
20702072 delete generate_data.tool_choice;
20712073 delete generate_data.stop;
2072- // It does support logit_bias, but the tokenizer used and its effect is yet unknown.
2074+ delete generate_data.logit_bias;
2073- // delete generate_data.logit_bias;
20742075 }
20752076
20762077 await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, generate_data);
@@ -2166,6 +2167,14 @@ function getStreamingReply(data, state) {
21662167 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
21672168 }
21682169 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 ?? '';
21692178 } else {
21702179 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
21712180 }
@@ -3124,6 +3133,7 @@ function loadOpenAISettings(data, settings) {
31243133 oai_settings.inline_image_quality = settings.inline_image_quality ?? default_settings.inline_image_quality;
31253134 oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check;
31263135 oai_settings.show_thoughts = settings.show_thoughts ?? default_settings.show_thoughts;
3136+ oai_settings.reasoning_effort = settings.reasoning_effort ?? default_settings.reasoning_effort;
31273137 oai_settings.seed = settings.seed ?? default_settings.seed;
31283138 oai_settings.n = settings.n ?? default_settings.n;
31293139
@@ -3253,6 +3263,9 @@ function loadOpenAISettings(data, settings) {
32533263 $('#n_openai').val(oai_settings.n);
32543264 $('#openai_show_thoughts').prop('checked', oai_settings.show_thoughts);
32553265
3266+ $('#openai_reasoning_effort').val(oai_settings.reasoning_effort);
3267+ $(`#openai_reasoning_effort option[value="${oai_settings.reasoning_effort}"]`).prop('selected', true);
3268+
32563269 if (settings.reverse_proxy !== undefined) oai_settings.reverse_proxy = settings.reverse_proxy;
32573270 $('#openai_reverse_proxy').val(oai_settings.reverse_proxy);
32583271
@@ -3513,6 +3526,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
35133526 continue_postfix: settings.continue_postfix,
35143527 function_calling: settings.function_calling,
35153528 show_thoughts: settings.show_thoughts,
3529+ reasoning_effort: settings.reasoning_effort,
35163530 seed: settings.seed,
35173531 n: settings.n,
35183532 };
@@ -3971,6 +3985,7 @@ function onSettingsPresetChange() {
39713985 continue_postfix: ['#continue_postfix', 'continue_postfix', false],
39723986 function_calling: ['#openai_function_calling', 'function_calling', true],
39733987 show_thoughts: ['#openai_show_thoughts', 'show_thoughts', true],
3988+ reasoning_effort: ['#openai_reasoning_effort', 'reasoning_effort', false],
39743989 seed: ['#seed_openai', 'seed', false],
39753990 n: ['#n_openai', 'n', false],
39763991 };
@@ -4027,7 +4042,7 @@ function getMaxContextOpenAI(value) {
40274042 if (oai_settings.max_context_unlocked) {
40284043 return unlocked_max;
40294044 }
40304045 else if (value.startsWith('o1-') || value.startsWith('o3')) {
40314046 return max_128k;
40324047 }
40334048 else if (value.includes('chatgpt-4o-latest') || value.includes('gpt-4-turbo') || value.includes('gpt-4o') || value.includes('gpt-4-1106') || value.includes('gpt-4-0125') || value.includes('gpt-4-vision')) {
@@ -4100,6 +4115,40 @@ function getMaxContextWindowAI(value) {
41004115 }
41014116}
41024117
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+
41034152async function onModelChange() {
41044153 biasCache = undefined;
41054154 let value = String($(this).val() || '');
@@ -4232,9 +4281,9 @@ async function onModelChange() {
42324281 $('#openai_max_context').attr('max', max_2mil);
42334282 } else if (value.includes('gemini-exp-1114') || value.includes('gemini-exp-1121') || value.includes('gemini-2.0-flash-thinking-exp-1219')) {
42344283 $('#openai_max_context').attr('max', max_32k);
42354284 } else if (value.includes('gemini-1.5-pro') || value.includes('gemini-exp-1206') || value.includes('gemini-2.0-pro')) {
42364285 $('#openai_max_context').attr('max', max_2mil);
42374286 } else if (value.includes('gemini-1.5-flash') || value.includes('gemini-2.0-flash-exp') || value.includes('gemini-2.0-flash-thinking-exp')) {
42384287 $('#openai_max_context').attr('max', max_1mil);
42394288 } else if (value.includes('gemini-1.0-pro') || value === 'gemini-pro') {
42404289 $('#openai_max_context').attr('max', max_32k);
@@ -4380,28 +4429,19 @@ async function onModelChange() {
43804429 if (oai_settings.max_context_unlocked) {
43814430 $('#openai_max_context').attr('max', unlocked_max);
43824431 }
4432+ else if (['sonar', 'sonar-reasoning', 'sonar-reasoning-pro', 'r1-1776'].includes(oai_settings.perplexity_model)) {
4433+ $('#openai_max_context').attr('max', 127000);
4434+ }
4435+ else if (['sonar-pro'].includes(oai_settings.perplexity_model)) {
4436+ $('#openai_max_context').attr('max', 200000);
4437+ }
43834438 else if (oai_settings.perplexity_model.includes('llama-3.1')) {
43844439 const isOnline = oai_settings.perplexity_model.includes('online');
43854440 const contextSize = isOnline ? 128 * 1024 - 4000 : 128 * 1024;
43864441 $('#openai_max_context').attr('max', contextSize);
43874442 }
4388- else if (['llama-3-sonar-small-32k-chat', 'llama-3-sonar-large-32k-chat'].includes(oai_settings.perplexity_model)) {
4389- $('#openai_max_context').attr('max', max_32k);
4390- }
4391- else if (['llama-3-sonar-small-32k-online', 'llama-3-sonar-large-32k-online'].includes(oai_settings.perplexity_model)) {
4392- $('#openai_max_context').attr('max', 28000);
4393- }
4394- else if (['sonar-small-chat', 'sonar-medium-chat', 'codellama-70b-instruct', 'mistral-7b-instruct', 'mixtral-8x7b-instruct', 'mixtral-8x22b-instruct'].includes(oai_settings.perplexity_model)) {
4395- $('#openai_max_context').attr('max', max_16k);
4396- }
4397- else if (['llama-3-8b-instruct', 'llama-3-70b-instruct'].includes(oai_settings.perplexity_model)) {
4398- $('#openai_max_context').attr('max', max_8k);
4399- }
4400- else if (['sonar-small-online', 'sonar-medium-online'].includes(oai_settings.perplexity_model)) {
4401- $('#openai_max_context').attr('max', 12000);
4402- }
44034443 else {
44044444 $('#openai_max_context').attr('max', max_4kmax_128k);
44054445 }
44064446 oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
44074447 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
@@ -4410,27 +4450,8 @@ async function onModelChange() {
44104450 }
44114451
44124452 if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) {
4413- if (oai_settings.max_context_unlocked) {
4453+ const maxContext = getGroqMaxContext(oai_settings.groq_model, oai_settings.max_context_unlocked);
44144454 $('#openai_max_context').attr('max', unlocked_maxmaxContext);
4415- }
4416- else if (oai_settings.groq_model.includes('llama-3.2') && oai_settings.groq_model.includes('-preview')) {
4417- $('#openai_max_context').attr('max', max_8k);
4418- }
4419- else if (oai_settings.groq_model.includes('llama-3.3') || oai_settings.groq_model.includes('llama-3.2') || oai_settings.groq_model.includes('llama-3.1')) {
4420- $('#openai_max_context').attr('max', max_128k);
4421- }
4422- else if (oai_settings.groq_model.includes('llama3-groq')) {
4423- $('#openai_max_context').attr('max', max_8k);
4424- }
4425- else if (['llama3-8b-8192', 'llama3-70b-8192', 'gemma-7b-it', 'gemma2-9b-it'].includes(oai_settings.groq_model)) {
4426- $('#openai_max_context').attr('max', max_8k);
4427- }
4428- else if (['mixtral-8x7b-32768'].includes(oai_settings.groq_model)) {
4429- $('#openai_max_context').attr('max', max_32k);
4430- }
4431- else {
4432- $('#openai_max_context').attr('max', max_4k);
4433- }
44344455 oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
44354456 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
44364457 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
@@ -4930,6 +4951,12 @@ export function isImageInliningSupported() {
49304951 // gultra just isn't being offered as multimodal, thanks google.
49314952 const visionSupportedModels = [
49324953 'gpt-4-vision',
4954+ 'gemini-2.0-pro-exp',
4955+ 'gemini-2.0-pro-exp-02-05',
4956+ 'gemini-2.0-flash-lite-preview',
4957+ 'gemini-2.0-flash-lite-preview-02-05',
4958+ 'gemini-2.0-flash',
4959+ 'gemini-2.0-flash-001',
49334960 'gemini-2.0-flash-thinking-exp-1219',
49344961 'gemini-2.0-flash-thinking-exp-01-21',
49354962 'gemini-2.0-flash-thinking-exp',
@@ -4957,6 +4984,8 @@ export function isImageInliningSupported() {
49574984 'gpt-4-turbo',
49584985 'gpt-4o',
49594986 'gpt-4o-mini',
4987+ 'o1',
4988+ 'o1-2024-12-17',
49604989 'chatgpt-4o-latest',
49614990 'yi-vision',
49624991 'pixtral-latest',
@@ -5515,6 +5544,11 @@ export function initOpenAI() {
55155544 saveSettingsDebounced();
55165545 });
55175546
5547+ $('#openai_reasoning_effort').on('input', function () {
5548+ oai_settings.reasoning_effort = String($(this).val());
5549+ saveSettingsDebounced();
5550+ });
5551+
55185552 if (!CSS.supports('field-sizing', 'content')) {
55195553 $(document).on('input', '#openai_settings .autoSetHeight', function () {
55205554 resetScrollHeight($(this));
public/scripts/personas.js+6 -5
@@ -30,6 +30,7 @@ import { t } from './i18n.js';
3030import { openWorldInfoEditor, world_names } from './world-info.js';
3131import { renderTemplateAsync } from './templates.js';
3232import { saveMetadataDebounced } from './extensions.js';
33+import { accountStorage } from './util/AccountStorage.js';
3334
3435/**
3536 * @typedef {object} PersonaConnection A connection between a character and a character or group entity
@@ -67,7 +68,7 @@ export function isPersonaPanelOpen() {
6768}
6869
6970function switchPersonaGridView() {
7071 const state = localStorageaccountStorage.getItem(GRID_STORAGE_KEY) === 'true';
7172 $('#user_avatar_block').toggleClass('gridView', state);
7273}
7374
@@ -218,7 +219,7 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
218219
219220 const storageKey = 'Personas_PerPage';
220221 const listId = '#user_avatar_block';
221222 const perPage = Number(localStorageaccountStorage.getItem(storageKey)) || 5;
222223
223224 $('#persona_pagination_container').pagination({
224225 dataSource: entities,
@@ -241,7 +242,7 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
241242 updatePersonaUIStates();
242243 },
243244 afterSizeSelectorChange: function (e) {
244245 localStorageaccountStorage.setItem(storageKey, e.target.value);
245246 },
246247 afterPaging: function (e) {
247248 savePersonasPage = e;
@@ -1631,8 +1632,8 @@ export function initPersonas() {
16311632 saveSettingsDebounced();
16321633 });
16331634 $('#persona_grid_toggle').on('click', () => {
16341635 const state = localStorageaccountStorage.getItem(GRID_STORAGE_KEY) === 'true';
16351636 localStorageaccountStorage.setItem(GRID_STORAGE_KEY, String(!state));
16361637 switchPersonaGridView();
16371638 });
16381639
public/scripts/popup.js+12 -1
@@ -24,6 +24,15 @@ export const POPUP_RESULT = {
2424 AFFIRMATIVE: 1,
2525 NEGATIVE: 0,
2626 CANCELLED: null,
27+ CUSTOM1: 1001,
28+ CUSTOM2: 1002,
29+ CUSTOM3: 1003,
30+ CUSTOM4: 1004,
31+ CUSTOM5: 1005,
32+ CUSTOM6: 1006,
33+ CUSTOM7: 1007,
34+ CUSTOM8: 1008,
35+ CUSTOM9: 1009,
2736};
2837
2938/**
@@ -37,6 +46,7 @@ export const POPUP_RESULT = {
3746 * @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content)
3847 * @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup
3948 * @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup
49+ * @property {boolean?} [leftAlign=false] - Whether the popup content should be left-aligned by default
4050 * @property {'slow'|'fast'|'none'?} [animation='slow'] - Animation speed for the popup (opening, closing, ...)
4151 * @property {POPUP_RESULT|number?} [defaultResult=POPUP_RESULT.AFFIRMATIVE] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`.
4252 * @property {CustomPopupButton[]|string[]?} [customButtons=null] - Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward.
@@ -164,7 +174,7 @@ export class Popup {
164174 * @param {string} [inputValue=''] - The initial value of the input field
165175 * @param {PopupOptions} [options={}] - Additional options for the popup
166176 */
167177 constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, leftAlign = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
168178 Popup.util.popups.push(this);
169179
170180 // Make this popup uniquely identifiable
@@ -209,6 +219,7 @@ export class Popup {
209219 if (transparent) this.dlg.classList.add('transparent_dialogue_popup');
210220 if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');
211221 if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup');
222+ if (leftAlign) this.dlg.classList.add('left_aligned_dialogue_popup');
212223 if (animation) this.dlg.classList.add('popup--animation-' + animation);
213224
214225 // If custom button captions are provided, we set them beforehand
public/scripts/power-user.js+95 -9
@@ -54,6 +54,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
5454import { POPUP_TYPE, callGenericPopup } from './popup.js';
5555import { loadSystemPrompts } from './sysprompt.js';
5656import { fuzzySearchCategories } from './filters.js';
57+import { accountStorage } from './util/AccountStorage.js';
5758
5859export {
5960 loadPowerUserSettings,
@@ -254,7 +255,10 @@ let power_user = {
254255 },
255256
256257 reasoning: {
258+ auto_parse: false,
257259 add_to_prompts: false,
260+ auto_expand: false,
261+ show_hidden: false,
258262 prefix: '<think>\n',
259263 suffix: '\n</think>',
260264 separator: '\n\n',
@@ -1843,14 +1847,15 @@ async function loadContextSettings() {
18431847
18441848/**
18451849 * Common function to perform fuzzy search with optional caching
1850+ * @template T
18461851 * @param {string} type - Type of search from fuzzySearchCategories
18471852 * @param {anyT[]} data - Data array to search in
18481853 * @param {Array<{name: string, weight: number, getFn?: (obj: anyT) => string}>} keys - Fuse.js keys configuration
18491854 * @param {string} searchValue - The search term
18501855 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
18511856 * @returns {import('fuse.js').FuseResult<anyT>[]} Results as items with their score
18521857 */
18531858export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
18541859 // Check cache if provided
18551860 if (fuzzySearchCaches) {
18561861 const cache = fuzzySearchCaches[type];
@@ -2019,7 +2024,7 @@ export function renderStoryString(params) {
20192024 */
20202025function validateStoryString(storyString, params) {
20212026 /** @type {{hashCache: {[hash: string]: {fieldsWarned: {[key: string]: boolean}}}}} */
20222027 const cache = JSON.parse(localStorageaccountStorage.getItem(storage_keys.storyStringValidationCache)) ?? { hashCache: {} };
20232028
20242029 const hash = getStringHash(storyString);
20252030
@@ -2056,7 +2061,7 @@ function validateStoryString(storyString, params) {
20562061 toastr.warning(`The story string does not contain the following fields, but they would contain content: ${fieldsList}`, 'Story String Validation');
20572062 }
20582063
20592064 localStorageaccountStorage.setItem(storage_keys.storyStringValidationCache, JSON.stringify(cache));
20602065}
20612066
20622067
@@ -2451,7 +2456,7 @@ async function resetMovablePanels(type) {
24512456 }
24522457
24532458 saveSettingsDebounced();
24542459 await eventSource.emit(event_types.MOVABLE_PANELS_RESET);
24552460
24562461 eventSource.once(event_types.SETTINGS_UPDATED, () => {
24572462 $('.resizing').removeClass('resizing');
@@ -2919,6 +2924,46 @@ export function flushEphemeralStoppingStrings() {
29192924}
29202925
29212926/**
2927+ * Checks if the generated text should be filtered based on the auto-swipe settings.
2928+ * @param {string} text The text to check
2929+ * @returns {boolean} If the generated text should be filtered
2930+ */
2931+export function generatedTextFiltered(text) {
2932+ /**
2933+ * Checks if the given text contains any of the blacklisted words.
2934+ * @param {string} text The text to check
2935+ * @param {string[]} blacklist The list of blacklisted words
2936+ * @param {number} threshold The number of blacklisted words that need to be present to trigger the check
2937+ * @returns {boolean} Whether the text contains blacklisted words
2938+ */
2939+ function containsBlacklistedWords(text, blacklist, threshold) {
2940+ const regex = new RegExp(`\\b(${blacklist.join('|')})\\b`, 'gi');
2941+ const matches = text.match(regex) || [];
2942+ return matches.length >= threshold;
2943+ }
2944+
2945+ // Make sure a generated text is non-empty
2946+ // Otherwise we might get in a loop with a broken API
2947+ text = text.trim();
2948+ if (text.length > 0) {
2949+ if (power_user.auto_swipe_minimum_length) {
2950+ if (text.length < power_user.auto_swipe_minimum_length) {
2951+ console.log('Generated text size too small');
2952+ return true;
2953+ }
2954+ }
2955+ if (power_user.auto_swipe_blacklist.length && power_user.auto_swipe_blacklist_threshold) {
2956+ if (containsBlacklistedWords(text, power_user.auto_swipe_blacklist, power_user.auto_swipe_blacklist_threshold)) {
2957+ console.log('Generated text has blacklisted words');
2958+ return true;
2959+ }
2960+ }
2961+ }
2962+
2963+ return false;
2964+}
2965+
2966+/**
29222967 * Gets the custom stopping strings from the power user settings.
29232968 * @param {number | undefined} limit Number of strings to return. If 0 or undefined, returns all strings.
29242969 * @returns {string[]} An array of custom stopping strings
@@ -3899,9 +3944,9 @@ $(document).ready(() => {
38993944 helpString: 'Start a new chat with a random character. If an argument is provided, only considers characters that have the specified tag.',
39003945 }));
39013946 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
39023947 name: 'delmodedel',
39033948 callback: doDelMode,
39043949 aliases: ['deldelete', 'delmode'],
39053950 unnamedArgumentList: [
39063951 new SlashCommandArgument(
39073952 'optional number', [ARGUMENT_TYPE.NUMBER], false,
@@ -4084,4 +4129,45 @@ $(document).ready(() => {
40844129 ],
40854130 helpString: 'activates a movingUI preset by name',
40864131 }));
4132+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
4133+ name: 'stop-strings',
4134+ aliases: ['stopping-strings', 'custom-stopping-strings', 'custom-stop-strings'],
4135+ helpString: `
4136+ <div>
4137+ Sets a list of custom stopping strings. Gets the list if no value is provided.
4138+ </div>
4139+ <div>
4140+ <strong>Examples:</strong>
4141+ </div>
4142+ <ul>
4143+ <li>Value must be a JSON-serialized array: <pre><code class="language-stscript">/stop-strings ["goodbye", "farewell"]</code></pre></li>
4144+ <li>Pipe characters must be escaped with a backslash: <pre><code class="language-stscript">/stop-strings ["left\\|right"]</code></pre></li>
4145+ </ul>
4146+ `,
4147+ returns: ARGUMENT_TYPE.LIST,
4148+ unnamedArgumentList: [
4149+ SlashCommandArgument.fromProps({
4150+ description: 'list of strings',
4151+ typeList: [ARGUMENT_TYPE.LIST],
4152+ acceptsMultiple: false,
4153+ isRequired: false,
4154+ }),
4155+ ],
4156+ callback: (_, value) => {
4157+ if (String(value ?? '').trim()) {
4158+ const parsedValue = ((x) => { try { return JSON.parse(x.toString()); } catch { return null; } })(value);
4159+ if (!parsedValue || !Array.isArray(parsedValue)) {
4160+ throw new Error('Invalid list format. The value must be a JSON-serialized array of strings.');
4161+ }
4162+ parsedValue.forEach((item, index) => {
4163+ parsedValue[index] = String(item);
4164+ });
4165+ power_user.custom_stopping_strings = JSON.stringify(parsedValue);
4166+ $('#custom_stopping_strings').val(power_user.custom_stopping_strings);
4167+ saveSettingsDebounced();
4168+ }
4169+
4170+ return power_user.custom_stopping_strings;
4171+ },
4172+ }));
40874173});
public/scripts/preset-manager.js+3 -0
@@ -586,6 +586,9 @@ class PresetManager {
586586 'tabby_model',
587587 'derived',
588588 'generic_model',
589+ 'include_reasoning',
590+ 'global_banned_tokens',
591+ 'send_banned_tokens',
589592 ];
590593 const settings = Object.assign({}, getSettingsByApiId(this.apiId));
591594
public/scripts/reasoning.js+765 -21
@@ -1,13 +1,32 @@
1-import { chat, closeMessageEditor, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js';
1+import {
2-import { t } from './i18n.js';
2+ moment,
3+} from '../lib.js';
4+import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js';
5+import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
6+import { getCurrentLocale, t, translate } from './i18n.js';
37import { MacrosParser } from './macros.js';
8+import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';
49import { Popup } from './popup.js';
510import { power_user } from './power-user.js';
611import { SlashCommand } from './slash-commands/SlashCommand.js';
712import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
813import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
14+import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
915import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
1016import { copyTexttextgen_types, textgenerationwebui_settings } from './utilstextgen-settings.js';
17+import { 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+};
1130
1231/**
1332 * Gets a message from a jQuery element.
@@ -22,12 +41,473 @@ function getMessageFromJquery(element) {
2241}
2342
2443/**
44+ * Toggles the auto-expand state of reasoning blocks.
45+ */
46+function toggleReasoningAutoExpand() {
47+ const reasoningBlocks = document.querySelectorAll('details.mes_reasoning_details');
48+ reasoningBlocks.forEach((block) => {
49+ if (block instanceof HTMLDetailsElement) {
50+ block.open = power_user.reasoning.auto_expand;
51+ }
52+ });
53+}
54+
55+/**
56+ * Extracts the reasoning from the response data.
57+ * @param {object} data Response data
58+ * @returns {string} Extracted reasoning
59+ */
60+export function extractReasoningFromData(data) {
61+ switch (main_api) {
62+ case 'textgenerationwebui':
63+ switch (textgenerationwebui_settings.type) {
64+ case textgen_types.OPENROUTER:
65+ return data?.choices?.[0]?.reasoning ?? '';
66+ }
67+ break;
68+
69+ case 'openai':
70+ if (!oai_settings.show_thoughts) break;
71+
72+ switch (oai_settings.chat_completion_source) {
73+ case chat_completion_sources.DEEPSEEK:
74+ return data?.choices?.[0]?.message?.reasoning_content ?? '';
75+ case chat_completion_sources.OPENROUTER:
76+ return data?.choices?.[0]?.message?.reasoning ?? '';
77+ case chat_completion_sources.MAKERSUITE:
78+ return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? '';
79+ case chat_completion_sources.CUSTOM: {
80+ return data?.choices?.[0]?.message?.reasoning_content
81+ ?? data?.choices?.[0]?.message?.reasoning
82+ ?? '';
83+ }
84+ }
85+ break;
86+ }
87+
88+ return '';
89+}
90+
91+/**
92+ * Check if the model supports reasoning, but does not send back the reasoning
93+ * @returns {boolean} True if the model supports reasoning
94+ */
95+export function isHiddenReasoningModel() {
96+ if (main_api !== 'openai') {
97+ return false;
98+ }
99+
100+ /** @typedef {{ (currentModel: string, supportedModel: string): boolean }} MatchingFunc */
101+ /** @type {Record.<string, MatchingFunc>} */
102+ const FUNCS = {
103+ equals: (currentModel, supportedModel) => currentModel === supportedModel,
104+ startsWith: (currentModel, supportedModel) => currentModel.startsWith(supportedModel),
105+ };
106+
107+ /** @type {{ name: string; func: MatchingFunc; }[]} */
108+ const hiddenReasoningModels = [
109+ { name: 'o1', func: FUNCS.startsWith },
110+ { name: 'o3', func: FUNCS.startsWith },
111+ { name: 'gemini-2.0-flash-thinking-exp', func: FUNCS.startsWith },
112+ { name: 'gemini-2.0-pro-exp', func: FUNCS.startsWith },
113+ ];
114+
115+ const model = getChatCompletionModel() || '';
116+
117+ const isHidden = hiddenReasoningModels.some(({ name, func }) => func(model, name));
118+ return isHidden;
119+}
120+
121+/**
122+ * Updates the Reasoning UI for a specific message
123+ * @param {number|JQuery<HTMLElement>|HTMLElement} messageIdOrElement The message ID or the message element
124+ * @param {Object} [options={}] - Optional arguments
125+ * @param {boolean} [options.reset=false] - Whether to reset state, and not take the current mess properties (for example when swiping)
126+ */
127+export function updateReasoningUI(messageIdOrElement, { reset = false } = {}) {
128+ const handler = new ReasoningHandler();
129+ handler.initHandleMessage(messageIdOrElement, { reset });
130+}
131+
132+
133+/**
134+ * Enum for representing the state of reasoning
135+ * @enum {string}
136+ * @readonly
137+ */
138+export const ReasoningState = {
139+ None: 'none',
140+ Thinking: 'thinking',
141+ Done: 'done',
142+ Hidden: 'hidden',
143+};
144+
145+/**
146+ * Handles reasoning-specific logic and DOM updates for messages.
147+ * This class is used inside the {@link StreamingProcessor} to manage reasoning states and UI updates.
148+ */
149+export class ReasoningHandler {
150+ /** @type {boolean} True if the model supports reasoning, but hides the reasoning output */
151+ #isHiddenReasoningModel;
152+ /** @type {boolean} True if the handler is currently handling a manual parse of reasoning blocks */
153+ #isParsingReasoning = false;
154+ /** @type {number?} When reasoning is being parsed manually, and the reasoning has ended, this will be the index at which the actual messages starts */
155+ #parsingReasoningMesStartIndex = null;
156+
157+ /**
158+ * @param {Date?} [timeStarted=null] - When the generation started
159+ */
160+ constructor(timeStarted = null) {
161+ /** @type {ReasoningState} The current state of the reasoning process */
162+ this.state = ReasoningState.None;
163+ /** @type {ReasoningType?} The type of the reasoning (where it came from) */
164+ this.type = null;
165+ /** @type {string} The reasoning output */
166+ this.reasoning = '';
167+ /** @type {Date} When the reasoning started */
168+ this.startTime = null;
169+ /** @type {Date} When the reasoning ended */
170+ this.endTime = null;
171+
172+ /** @type {Date} Initial starting time of the generation */
173+ this.initialTime = timeStarted ?? new Date();
174+
175+ this.#isHiddenReasoningModel = isHiddenReasoningModel();
176+
177+ // Cached DOM elements for reasoning
178+ /** @type {HTMLElement} Main message DOM element `.mes` */
179+ this.messageDom = null;
180+ /** @type {HTMLDetailsElement} Reasoning details DOM element `.mes_reasoning_details` */
181+ this.messageReasoningDetailsDom = null;
182+ /** @type {HTMLElement} Reasoning content DOM element `.mes_reasoning` */
183+ this.messageReasoningContentDom = null;
184+ /** @type {HTMLElement} Reasoning header DOM element `.mes_reasoning_header_title` */
185+ this.messageReasoningHeaderDom = null;
186+ }
187+
188+ /**
189+ * Initializes the reasoning handler for a specific message.
190+ *
191+ * Can be used to update the DOM elements or read other reasoning states.
192+ * It will internally take the message-saved data and write the states back into the handler, as if during streaming of the message.
193+ * The state will always be either done/hidden or none.
194+ *
195+ * @param {number|JQuery<HTMLElement>|HTMLElement} messageIdOrElement - The message ID or the message element
196+ * @param {Object} [options={}] - Optional arguments
197+ * @param {boolean} [options.reset=false] - Whether to reset state of the handler, and not take the current mess properties (for example when swiping)
198+ */
199+ initHandleMessage(messageIdOrElement, { reset = false } = {}) {
200+ /** @type {HTMLElement} */
201+ const messageElement = typeof messageIdOrElement === 'number'
202+ ? document.querySelector(`#chat [mesid="${messageIdOrElement}"]`)
203+ : messageIdOrElement instanceof HTMLElement
204+ ? messageIdOrElement
205+ : $(messageIdOrElement)[0];
206+ const messageId = Number(messageElement.getAttribute('mesid'));
207+
208+ if (isNaN(messageId) || !chat[messageId]) return;
209+
210+ if (!chat[messageId].extra) {
211+ chat[messageId].extra = {};
212+ }
213+ const extra = chat[messageId].extra;
214+
215+ if (extra.reasoning) {
216+ this.state = ReasoningState.Done;
217+ } else if (extra.reasoning_duration) {
218+ this.state = ReasoningState.Hidden;
219+ }
220+
221+ this.type = extra?.reasoning_type;
222+ this.reasoning = extra?.reasoning ?? '';
223+
224+ if (this.state !== ReasoningState.None) {
225+ this.initialTime = new Date(chat[messageId].gen_started);
226+ this.startTime = this.initialTime;
227+ this.endTime = new Date(this.startTime.getTime() + (extra?.reasoning_duration ?? 0));
228+ }
229+
230+ // Prefill main dom element, as message might not have been rendered yet
231+ this.messageDom = messageElement;
232+
233+ // Make sure reset correctly clears all relevant states
234+ if (reset) {
235+ this.state = this.#isHiddenReasoningModel ? ReasoningState.Thinking : ReasoningState.None;
236+ this.type = null;
237+ this.reasoning = '';
238+ this.initialTime = new Date();
239+ this.startTime = null;
240+ this.endTime = null;
241+ }
242+
243+ this.updateDom(messageId);
244+
245+ if (power_user.reasoning.auto_expand && this.state !== ReasoningState.Hidden) {
246+ this.messageReasoningDetailsDom.open = true;
247+ }
248+ }
249+
250+ /**
251+ * Gets the duration of the reasoning in milliseconds.
252+ *
253+ * @returns {number?} The duration in milliseconds, or null if the start or end time is not set
254+ */
255+ getDuration() {
256+ if (this.startTime && this.endTime) {
257+ return this.endTime.getTime() - this.startTime.getTime();
258+ }
259+ return null;
260+ }
261+
262+ /**
263+ * Updates the reasoning text/string for a message.
264+ *
265+ * @param {number} messageId - The ID of the message to update
266+ * @param {string?} [reasoning=null] - The reasoning text to update - If null or empty, uses the current reasoning
267+ * @param {Object} [options={}] - Optional arguments
268+ * @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
270+ * @returns {boolean} - Returns true if the reasoning was changed, otherwise false
271+ */
272+ updateReasoning(messageId, reasoning = null, { persist = false, allowReset = false } = {}) {
273+ if (messageId == -1 || !chat[messageId]) {
274+ return false;
275+ }
276+
277+ reasoning = allowReset ? reasoning ?? this.reasoning : reasoning || this.reasoning;
278+ reasoning = trimSpaces(reasoning);
279+
280+ // Ensure the chat extra exists
281+ if (!chat[messageId].extra) {
282+ chat[messageId].extra = {};
283+ }
284+ const extra = chat[messageId].extra;
285+
286+ const reasoningChanged = extra.reasoning !== reasoning;
287+ this.reasoning = getRegexedString(reasoning ?? '', regex_placement.REASONING);
288+
289+ this.type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
290+
291+ if (persist) {
292+ // Build and save the reasoning data to message extras
293+ extra.reasoning = this.reasoning;
294+ extra.reasoning_duration = this.getDuration();
295+ extra.reasoning_type = (this.#isParsingReasoning || this.#parsingReasoningMesStartIndex) ? ReasoningType.Parsed : ReasoningType.Model;
296+ }
297+
298+ return reasoningChanged;
299+ }
300+
301+
302+ /**
303+ * Handles processing of reasoning for a message.
304+ *
305+ * This is usually called by the message processor when a message is changed.
306+ *
307+ * @param {number} messageId - The ID of the message to process
308+ * @param {boolean} mesChanged - Whether the message has changed
309+ * @returns {Promise<void>}
310+ */
311+ async process(messageId, mesChanged) {
312+ mesChanged = this.#autoParseReasoningFromMessage(messageId, mesChanged);
313+
314+ if (!this.reasoning && !this.#isHiddenReasoningModel)
315+ return;
316+
317+ // Ensure reasoning string is updated and regexes are applied correctly
318+ const reasoningChanged = this.updateReasoning(messageId, null, { persist: true });
319+
320+ if ((this.#isHiddenReasoningModel || reasoningChanged) && this.state === ReasoningState.None) {
321+ this.state = ReasoningState.Thinking;
322+ this.startTime = this.initialTime;
323+ }
324+ if ((this.#isHiddenReasoningModel || !reasoningChanged) && mesChanged && this.state === ReasoningState.Thinking) {
325+ this.endTime = new Date();
326+ await this.finish(messageId);
327+ }
328+ }
329+
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+
378+ /**
379+ * Completes the reasoning process for a message.
380+ *
381+ * Records the finish time if it was not set during streaming and updates the reasoning state.
382+ * Emits an event to signal the completion of reasoning and updates the DOM elements accordingly.
383+ *
384+ * @param {number} messageId - The ID of the message to complete reasoning for
385+ * @returns {Promise<void>}
386+ */
387+ async finish(messageId) {
388+ if (this.state === ReasoningState.None) return;
389+
390+ // Make sure the finish time is recorded if a reasoning was in process and it wasn't ended correctly during streaming
391+ if (this.startTime !== null && this.endTime === null) {
392+ this.endTime = new Date();
393+ }
394+
395+ if (this.state === ReasoningState.Thinking) {
396+ this.state = this.#isHiddenReasoningModel ? ReasoningState.Hidden : ReasoningState.Done;
397+ this.updateReasoning(messageId, null, { persist: true });
398+ await eventSource.emit(event_types.STREAM_REASONING_DONE, this.reasoning, this.getDuration(), messageId, this.state);
399+ }
400+
401+ this.updateDom(messageId);
402+ }
403+
404+ /**
405+ * Updates the reasoning UI elements for a message.
406+ *
407+ * Toggles the CSS class, updates states, reasoning message, and duration.
408+ *
409+ * @param {number} messageId - The ID of the message to update
410+ */
411+ updateDom(messageId) {
412+ this.#checkDomElements(messageId);
413+
414+ // Main CSS class to show this message includes reasoning
415+ this.messageDom.classList.toggle('reasoning', this.state !== ReasoningState.None);
416+
417+ // Update states to the relevant DOM elements
418+ setDatasetProperty(this.messageDom, 'reasoningState', this.state !== ReasoningState.None ? this.state : null);
419+ setDatasetProperty(this.messageReasoningDetailsDom, 'state', this.state);
420+ setDatasetProperty(this.messageReasoningDetailsDom, 'type', this.type);
421+
422+ // Update the reasoning message
423+ const reasoning = trimSpaces(this.reasoning);
424+ const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
425+ this.messageReasoningContentDom.innerHTML = displayReasoning;
426+
427+ // Update tooltip for hidden reasoning edit
428+ /** @type {HTMLElement} */
429+ const button = this.messageDom.querySelector('.mes_edit_add_reasoning');
430+ button.title = this.state === ReasoningState.Hidden ? t`Hidden reasoning - Add reasoning block` : t`Add reasoning block`;
431+
432+ // Make sure that hidden reasoning headers are collapsed by default, to not show a useless edit button
433+ if (this.state === ReasoningState.Hidden) {
434+ this.messageReasoningDetailsDom.open = false;
435+ }
436+
437+ // Update the reasoning duration in the UI
438+ this.#updateReasoningTimeUI();
439+ }
440+
441+ /**
442+ * Finds and caches reasoning-related DOM elements for the given message.
443+ *
444+ * @param {number} messageId - The ID of the message to cache the DOM elements for
445+ */
446+ #checkDomElements(messageId) {
447+ // Make sure we reset dom elements if we are checking for a different message (shouldn't happen, but be sure)
448+ if (this.messageDom !== null && this.messageDom.getAttribute('mesid') !== messageId.toString()) {
449+ this.messageDom = null;
450+ }
451+
452+ // Cache the DOM elements once
453+ if (this.messageDom === null) {
454+ this.messageDom = document.querySelector(`#chat .mes[mesid="${messageId}"]`);
455+ if (this.messageDom === null) throw new Error('message dom does not exist');
456+ }
457+ if (this.messageReasoningDetailsDom === null) {
458+ this.messageReasoningDetailsDom = this.messageDom.querySelector('.mes_reasoning_details');
459+ }
460+ if (this.messageReasoningContentDom === null) {
461+ this.messageReasoningContentDom = this.messageDom.querySelector('.mes_reasoning');
462+ }
463+ if (this.messageReasoningHeaderDom === null) {
464+ this.messageReasoningHeaderDom = this.messageDom.querySelector('.mes_reasoning_header_title');
465+ }
466+ }
467+
468+ /**
469+ * Updates the reasoning time display in the UI.
470+ *
471+ * Shows the duration in a human-readable format with a tooltip for exact seconds.
472+ * Displays "Thinking..." if still processing, or a generic message otherwise.
473+ */
474+ #updateReasoningTimeUI() {
475+ const element = this.messageReasoningHeaderDom;
476+ const duration = this.getDuration();
477+ let data = null;
478+ let title = '';
479+ if (duration) {
480+ const seconds = moment.duration(duration).asSeconds();
481+
482+ const durationStr = moment.duration(duration).locale(getCurrentLocale()).humanize({ s: 50, ss: 3 });
483+ element.textContent = t`Thought for ${durationStr}`;
484+ data = String(seconds);
485+ title = `${seconds} seconds`;
486+ } else if ([ReasoningState.Done, ReasoningState.Hidden].includes(this.state)) {
487+ element.textContent = t`Thought for some time`;
488+ data = 'unknown';
489+ } else {
490+ element.textContent = t`Thinking...`;
491+ data = null;
492+ }
493+
494+ if (this.type !== ReasoningType.Model) {
495+ title += ` [${translate(this.type)}]`;
496+ title = title.trim();
497+ }
498+ element.title = title;
499+
500+ setDatasetProperty(this.messageReasoningDetailsDom, 'duration', data);
501+ setDatasetProperty(element, 'duration', data);
502+ }
503+}
504+
505+/**
25506 * Helper class for adding reasoning to messages.
26507 * Keeps track of the number of reasoning additions.
27508 */
28509export class PromptReasoning {
29510 static REASONING_PLACEHOLDER = '\u200B';
30- static REASONING_PLACEHOLDER_REGEX = new RegExp(`${PromptReasoning.REASONING_PLACEHOLDER}$`);
31511
32512 constructor() {
33513 this.counter = 0;
@@ -49,15 +529,16 @@ export class PromptReasoning {
49529 * Add reasoning to a message according to the power user settings.
50530 * @param {string} content Message content
51531 * @param {string} reasoning Message reasoning
532+ * @param {boolean} isPrefix Whether this is the last message prefix
52533 * @returns {string} Message content with reasoning
53534 */
54535 addToMessage(content, reasoning, isPrefix) {
55536 // Disabled or reached limit of additions
56537 if (!isPrefix && (!power_user.reasoning.add_to_prompts || this.counter >= power_user.reasoning.max_additions)) {
57538 return content;
58539 }
59540
60541 // No reasoning provided or a legacy placeholder
61542 if (!reasoning || reasoning === PromptReasoning.REASONING_PLACEHOLDER) {
62543 return content;
63544 }
@@ -70,6 +551,11 @@ export class PromptReasoning {
70551 const separator = substituteParams(power_user.reasoning.separator || '');
71552 const suffix = substituteParams(power_user.reasoning.suffix || '');
72553
554+ // Combine parts with reasoning only
555+ if (isPrefix && !content) {
556+ return `${prefix}${reasoning}`;
557+ }
558+
73559 // Combine parts with reasoning and content
74560 return `${prefix}${reasoning}${suffix}${separator}${content}`;
75561 }
@@ -105,11 +591,34 @@ function loadReasoningSettings() {
105591 power_user.reasoning.max_additions = Number($(this).val());
106592 saveSettingsDebounced();
107593 });
594+
595+ $('#reasoning_auto_parse').prop('checked', power_user.reasoning.auto_parse);
596+ $('#reasoning_auto_parse').on('change', function () {
597+ power_user.reasoning.auto_parse = !!$(this).prop('checked');
598+ saveSettingsDebounced();
599+ });
600+
601+ $('#reasoning_auto_expand').prop('checked', power_user.reasoning.auto_expand);
602+ $('#reasoning_auto_expand').on('change', function () {
603+ power_user.reasoning.auto_expand = !!$(this).prop('checked');
604+ toggleReasoningAutoExpand();
605+ saveSettingsDebounced();
606+ });
607+ toggleReasoningAutoExpand();
608+
609+ $('#reasoning_show_hidden').prop('checked', power_user.reasoning.show_hidden);
610+ $('#reasoning_show_hidden').on('change', function () {
611+ power_user.reasoning.show_hidden = !!$(this).prop('checked');
612+ $('#chat').attr('data-show-hidden-reasoning', power_user.reasoning.show_hidden ? 'true' : null);
613+ saveSettingsDebounced();
614+ });
615+ $('#chat').attr('data-show-hidden-reasoning', power_user.reasoning.show_hidden ? 'true' : null);
108616}
109617
110618function registerReasoningSlashCommands() {
111619 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
112620 name: 'reasoning-get',
621+ aliases: ['get-reasoning'],
113622 returns: ARGUMENT_TYPE.STRING,
114623 helpString: t`Get the contents of a reasoning block of a message. Returns an empty string if the message does not have a reasoning block.`,
115624 unnamedArgumentList: [
@@ -120,15 +629,16 @@ function registerReasoningSlashCommands() {
120629 }),
121630 ],
122631 callback: (_args, value) => {
123632 const messageId = !isNaN(NumberparseInt(value.toString())) ? NumberparseInt(value.toString()) : chat.length - 1;
124633 const message = chat[messageId];
125634 const reasoning = String(message?.extra?.reasoning ?? '');
126- return reasoning.replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, '');
635+ return reasoning;
127636 },
128637 }));
129638
130639 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
131640 name: 'reasoning-set',
641+ aliases: ['set-reasoning'],
132642 returns: ARGUMENT_TYPE.STRING,
133643 helpString: t`Set the reasoning block of a message. Returns the reasoning block content.`,
134644 namedArgumentList: [
@@ -146,13 +656,18 @@ function registerReasoningSlashCommands() {
146656 }),
147657 ],
148658 callback: async (args, value) => {
149659 const messageId = !isNaN(Number(args[0].at)) ? Number(args[0].at) : chat.length - 1;
150660 const message = chat[messageId];
151661 if (!message?.extra) {
152662 return '';
153663 }
664+ // Make sure the message has an extra object
665+ if (!message.extra || typeof message.extra !== 'object') {
666+ message.extra = {};
667+ }
154668
155669 message.extra.reasoning = String(value ?? '');
670+ message.extra.reasoning_type = ReasoningType.Manual;
156671 await saveChatConditional();
157672
158673 closeMessageEditor('reasoning');
@@ -160,6 +675,77 @@ function registerReasoningSlashCommands() {
160675 return message.extra.reasoning;
161676 },
162677 }));
678+
679+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
680+ name: 'reasoning-parse',
681+ aliases: ['parse-reasoning'],
682+ returns: 'reasoning string',
683+ helpString: t`Extracts the reasoning block from a string using the Reasoning Formatting settings.`,
684+ namedArgumentList: [
685+ SlashCommandNamedArgument.fromProps({
686+ name: 'regex',
687+ description: 'Whether to apply regex scripts to the reasoning content.',
688+ typeList: [ARGUMENT_TYPE.BOOLEAN],
689+ defaultValue: 'true',
690+ isRequired: false,
691+ enumList: commonEnumProviders.boolean('trueFalse')(),
692+ }),
693+ SlashCommandNamedArgument.fromProps({
694+ name: 'return',
695+ description: 'Whether to return the parsed reasoning or the content without reasoning',
696+ typeList: [ARGUMENT_TYPE.STRING],
697+ defaultValue: 'reasoning',
698+ isRequired: false,
699+ enumList: [
700+ new SlashCommandEnumValue('reasoning', null, enumTypes.enum, enumIcons.reasoning),
701+ new SlashCommandEnumValue('content', null, enumTypes.enum, enumIcons.message),
702+ ],
703+ }),
704+ SlashCommandNamedArgument.fromProps({
705+ name: 'strict',
706+ description: 'Whether to require the reasoning block to be at the beginning of the string (excluding whitespaces).',
707+ typeList: [ARGUMENT_TYPE.BOOLEAN],
708+ defaultValue: 'true',
709+ isRequired: false,
710+ enumList: commonEnumProviders.boolean('trueFalse')(),
711+ }),
712+ ],
713+ unnamedArgumentList: [
714+ SlashCommandArgument.fromProps({
715+ description: 'input string',
716+ typeList: [ARGUMENT_TYPE.STRING],
717+ }),
718+ ],
719+ callback: (args, value) => {
720+ if (!value || typeof value !== 'string') {
721+ return '';
722+ }
723+
724+ if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
725+ toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Parse`);
726+ return value;
727+ }
728+ if (typeof args.return !== 'string' || !['reasoning', 'content'].includes(args.return)) {
729+ toastr.warning(t`Invalid return type '${args.return}', defaulting to 'reasoning'.`, t`Reasoning Parse`);
730+ }
731+
732+ const returnMessage = args.return === 'content';
733+
734+ const parsedReasoning = parseReasoningFromString(value, { strict: !isFalseBoolean(String(args.strict ?? '')) });
735+ if (!parsedReasoning) {
736+ return returnMessage ? value : '';
737+ }
738+
739+ if (returnMessage) {
740+ return parsedReasoning.content;
741+ }
742+
743+ const applyRegex = !isFalseBoolean(String(args.regex ?? ''));
744+ return applyRegex
745+ ? getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING)
746+ : parsedReasoning.reasoning;
747+ },
748+ }));
163749}
164750
165751function registerReasoningMacros() {
@@ -169,6 +755,31 @@ function registerReasoningMacros() {
169755}
170756
171757function setReasoningEventHandlers() {
758+ $(document).on('click', '.mes_reasoning_details', function (e) {
759+ if (!e.target.closest('.mes_reasoning_actions') && !e.target.closest('.mes_reasoning_header')) {
760+ e.preventDefault();
761+ }
762+ });
763+
764+ $(document).on('click', '.mes_reasoning_header', function (e) {
765+ const details = $(this).closest('.mes_reasoning_details');
766+ // Along with the CSS rules to mark blocks not toggle-able when they are empty, prevent them from actually being toggled, or being edited
767+ if (details.find('.mes_reasoning').is(':empty')) {
768+ e.preventDefault();
769+ return;
770+ }
771+
772+ // If we are in message edit mode and reasoning area is closed, a click opens and edits it
773+ const mes = $(this).closest('.mes');
774+ const mesEditArea = mes.find('#curEditTextarea');
775+ if (mesEditArea.length) {
776+ const summary = $(mes).find('.mes_reasoning_summary');
777+ if (!summary.attr('open')) {
778+ summary.find('.mes_reasoning_edit').trigger('click');
779+ }
780+ }
781+ });
782+
172783 $(document).on('click', '.mes_reasoning_copy', (e) => {
173784 e.stopPropagation();
174785 e.preventDefault();
@@ -187,7 +798,7 @@ function setReasoningEventHandlers(){
187798 const textarea = document.createElement('textarea');
188799 const reasoningBlock = messageBlock.find('.mes_reasoning');
189800 textarea.classList.add('reasoning_edit_textarea');
190801 textarea.value = reasoning.replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, '');
191802 $(textarea).insertBefore(reasoningBlock);
192803
193804 if (!CSS.supports('field-sizing', 'content')) {
@@ -224,11 +835,14 @@ function setReasoningEventHandlers(){
224835 }
225836
226837 const textarea = messageBlock.find('.reasoning_edit_textarea');
227838 const reasoning = getRegexedString(String(textarea.val()), regex_placement.REASONING, { isEdit: true });
228839 message.extra.reasoning = reasoning;
840+ message.extra.reasoning_type = message.extra.reasoning_type ? ReasoningType.Edited : ReasoningType.Manual;
229841 await saveChatConditional();
230842 updateMessageBlock(messageId, message);
231843 textarea.remove();
844+
845+ messageBlock.find('.mes_edit_done:visible').trigger('click');
232846 });
233847
234848 $(document).on('click', '.mes_reasoning_edit_cancel', function (e) {
@@ -238,10 +852,14 @@ function setReasoningEventHandlers(){
238852 const { messageBlock } = getMessageFromJquery(this);
239853 const textarea = messageBlock.find('.reasoning_edit_textarea');
240854 textarea.remove();
855+
856+ messageBlock.find('.mes_reasoning_edit_cancel:visible').trigger('click');
857+
858+ updateReasoningUI(messageBlock);
241859 });
242860
243861 $(document).on('click', '.mes_edit_add_reasoning', async function () {
244862 const { message, messageIdmessageBlock } = getMessageFromJquery(this);
245863 if (!message?.extra) {
246864 return;
247865 }
@@ -251,34 +869,46 @@ function setReasoningEventHandlers(){
251869 return;
252870 }
253871
254- message.extra.reasoning = PromptReasoning.REASONING_PLACEHOLDER;
872+ messageBlock.addClass('reasoning');
873+
874+ // To make hidden reasoning blocks editable, we just set them to "Done" here already.
875+ // They will be done on save anyway - and on cancel the reasoning block gets rerendered too.
876+ if (messageBlock.attr('data-reasoning-state') === ReasoningState.Hidden) {
877+ messageBlock.attr('data-reasoning-state', ReasoningState.Done);
878+ }
879+
880+ // Open the reasoning area so we can actually edit it
881+ messageBlock.find('.mes_reasoning_details').attr('open', '');
882+ messageBlock.find('.mes_reasoning_edit').trigger('click');
255883 await saveChatConditional();
256- closeMessageEditor();
257- updateMessageBlock(messageId, message);
258884 });
259885
260886 $(document).on('click', '.mes_reasoning_delete', async function (e) {
261887 e.stopPropagation();
262888 e.preventDefault();
263889
264890 const confirm = await Popup.show.confirm(t`Remove Reasoning`, t`Are you sure you want to clear the reasoning?`,<br t`/>Visible message contents will stay intact.`);
265891
266892 if (!confirm) {
267893 return;
268894 }
269895
270896 const { message, messageId, messageBlock } = getMessageFromJquery(this);
271897 if (!message?.extra) {
272898 return;
273899 }
274900 message.extra.reasoning = '';
901+ delete message.extra.reasoning_type;
902+ delete message.extra.reasoning_duration;
275903 await saveChatConditional();
276904 updateMessageBlock(messageId, message);
905+ const textarea = messageBlock.find('.reasoning_edit_textarea');
906+ textarea.remove();
277907 });
278908
279909 $(document).on('pointerup', '.mes_reasoning_copy', async function () {
280910 const { message } = getMessageFromJquery(this);
281911 const reasoning = String(message?.extra?.reasoning ?? '').replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, '');
282912
283913 if (!reasoning) {
284914 return;
@@ -289,9 +919,123 @@ function setReasoningEventHandlers(){
289919 });
290920}
291921
922+/**
923+ * Removes reasoning from a string if auto-parsing is enabled.
924+ * @param {string} str Input string
925+ * @returns {string} Output string
926+ */
927+export function removeReasoningFromString(str) {
928+ if (!power_user.reasoning.auto_parse) {
929+ return str;
930+ }
931+
932+ const parsedReasoning = parseReasoningFromString(str);
933+ return parsedReasoning?.content ?? str;
934+}
935+
936+/**
937+ * Parses reasoning from a string using the power user reasoning settings.
938+ * @typedef {Object} ParsedReasoning
939+ * @property {string} reasoning Reasoning block
940+ * @property {string} content Message content
941+ * @param {string} str Content of the message
942+ * @param {Object} options Optional arguments
943+ * @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
944+ * @returns {ParsedReasoning|null} Parsed reasoning block and message content
945+ */
946+function parseReasoningFromString(str, { strict = true } = {}) {
947+ // Both prefix and suffix must be defined
948+ if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
949+ return null;
950+ }
951+
952+ try {
953+ const regex = new RegExp(`${(strict ? '^\\s*?' : '')}${escapeRegex(power_user.reasoning.prefix)}(.*?)${escapeRegex(power_user.reasoning.suffix)}`, 's');
954+
955+ let didReplace = false;
956+ let reasoning = '';
957+ let content = String(str).replace(regex, (_match, captureGroup) => {
958+ didReplace = true;
959+ reasoning = captureGroup;
960+ return '';
961+ });
962+
963+ if (didReplace) {
964+ reasoning = trimSpaces(reasoning);
965+ content = trimSpaces(content);
966+ }
967+
968+ return { reasoning, content };
969+ } catch (error) {
970+ console.error('[Reasoning] Error parsing reasoning block', error);
971+ return null;
972+ }
973+}
974+
975+function registerReasoningAppEvents() {
976+ eventSource.makeFirst(event_types.MESSAGE_RECEIVED, (/** @type {number} */ idx) => {
977+ if (!power_user.reasoning.auto_parse) {
978+ return;
979+ }
980+
981+ console.debug('[Reasoning] Auto-parsing reasoning block for message', idx);
982+ const message = chat[idx];
983+
984+ if (!message) {
985+ console.warn('[Reasoning] Message not found', idx);
986+ return null;
987+ }
988+
989+ if (!message.mes || message.mes === '...') {
990+ console.debug('[Reasoning] Message content is empty or a placeholder', idx);
991+ return null;
992+ }
993+
994+ if (message.extra?.reasoning) {
995+ console.debug('[Reasoning] Message already has reasoning', idx);
996+ return null;
997+ }
998+
999+ const parsedReasoning = parseReasoningFromString(message.mes);
1000+
1001+ // No reasoning block found
1002+ if (!parsedReasoning) {
1003+ return;
1004+ }
1005+
1006+ // Make sure the message has an extra object
1007+ if (!message.extra || typeof message.extra !== 'object') {
1008+ message.extra = {};
1009+ }
1010+
1011+ const contentUpdated = !!parsedReasoning.reasoning || parsedReasoning.content !== message.mes;
1012+
1013+ // If reasoning was found, add it to the message
1014+ if (parsedReasoning.reasoning) {
1015+ message.extra.reasoning = getRegexedString(parsedReasoning.reasoning, regex_placement.REASONING);
1016+ message.extra.reasoning_type = ReasoningType.Parsed;
1017+ }
1018+
1019+ // Update the message text if it was changed
1020+ if (parsedReasoning.content !== message.mes) {
1021+ message.mes = parsedReasoning.content;
1022+ }
1023+
1024+ // Find if a message already exists in DOM and must be updated
1025+ if (contentUpdated) {
1026+ const messageRendered = document.querySelector(`.mes[mesid="${idx}"]`) !== null;
1027+ if (messageRendered) {
1028+ console.debug('[Reasoning] Updating message block', idx);
1029+ updateMessageBlock(idx, message);
1030+ }
1031+ }
1032+ });
1033+}
1034+
2921035export function initReasoning() {
2931036 loadReasoningSettings();
2941037 setReasoningEventHandlers();
2951038 registerReasoningSlashCommands();
2961039 registerReasoningMacros();
1040+ registerReasoningAppEvents();
2971041}
public/scripts/secrets.js+2 -0
@@ -40,6 +40,8 @@ export const SECRET_KEYS = {
4040 BFL: 'api_key_bfl',
4141 GENERIC: 'api_key_generic',
4242 DEEPSEEK: 'api_key_deepseek',
43+ SERPER: 'api_key_serper',
44+ FALAI: 'api_key_falai',
4345};
4446
4547const INPUT_MAP = {
public/scripts/slash-commands.js+127 -47
@@ -59,7 +59,7 @@ import { autoSelectPersona, isPersonaLocked, retriggerFirstMessageOnEmptyChat, s
5959import { addEphemeralStoppingString, chat_styles, flushEphemeralStoppingStrings, power_user } from './power-user.js';
6060import { SERVER_INPUTS, textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
6161import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js';
6262import { debounce, delay, equalsIgnoreCaseAndAccents, findChar, getCharIndex, isFalseBoolean, isTrueBoolean, onlyUnique, regexFromString, showFontAwesomePicker, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.js';
6363import { registerVariableCommands, resolveVariable } from './variables.js';
6464import { background_settings } from './backgrounds.js';
6565import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
@@ -76,6 +76,7 @@ import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakC
7676import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
7777import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
7878import { t } from './i18n.js';
79+import { accountStorage } from './util/AccountStorage.js';
7980export {
8081 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
8182};
@@ -283,7 +284,6 @@ export function initDefaultSlashCommands() {
283284 description: 'Character name - or unique character identifier (avatar key)',
284285 typeList: [ARGUMENT_TYPE.STRING],
285286 enumProvider: commonEnumProviders.characters('character'),
286- forceEnum: false,
287287 }),
288288 ],
289289 helpString: `
@@ -322,7 +322,6 @@ export function initDefaultSlashCommands() {
322322 typeList: [ARGUMENT_TYPE.STRING],
323323 isRequired: true,
324324 enumProvider: commonEnumProviders.characters('character'),
325- forceEnum: false,
326325 }),
327326 SlashCommandNamedArgument.fromProps({
328327 name: 'avatar',
@@ -566,7 +565,6 @@ export function initDefaultSlashCommands() {
566565 typeList: [ARGUMENT_TYPE.STRING],
567566 isRequired: true,
568567 enumProvider: commonEnumProviders.characters('all'),
569- forceEnum: true,
570568 }),
571569 ],
572570 helpString: 'Opens up a chat with the character or group by its name',
@@ -782,6 +780,57 @@ export function initDefaultSlashCommands() {
782780 helpString: 'Unhides a message from the prompt.',
783781 }));
784782 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
783+ name: 'member-get',
784+ aliases: ['getmember', 'memberget'],
785+ callback: (async ({ field = 'name' }, arg) => {
786+ if (!selected_group) {
787+ toastr.warning('Cannot run /member-get command outside of a group chat.');
788+ return '';
789+ }
790+ if (field === '') {
791+ toastr.warning('\'/member-get field=\' argument required!');
792+ return '';
793+ }
794+ field = field.toString();
795+ arg = arg.toString();
796+ if (!['name', 'index', 'id', 'avatar'].includes(field)) {
797+ toastr.warning('\'/member-get field=\' argument required!');
798+ return '';
799+ }
800+ const isId = !isNaN(parseInt(arg));
801+ const groupMember = findGroupMemberId(arg, true);
802+ if (!groupMember) {
803+ toastr.warn(`No group member found using ${isId ? 'id' : 'string'} ${arg}`);
804+ return '';
805+ }
806+ return groupMember[field];
807+ }),
808+ namedArgumentList: [
809+ SlashCommandNamedArgument.fromProps({
810+ name: 'field',
811+ description: 'Whether to retrieve the name, index, id, or avatar.',
812+ typeList: [ARGUMENT_TYPE.STRING],
813+ isRequired: true,
814+ defaultValue: 'name',
815+ enumList: [
816+ new SlashCommandEnumValue('name', 'Character name'),
817+ new SlashCommandEnumValue('index', 'Group member index'),
818+ new SlashCommandEnumValue('avatar', 'Character avatar'),
819+ new SlashCommandEnumValue('id', 'Character index'),
820+ ],
821+ }),
822+ ],
823+ unnamedArgumentList: [
824+ SlashCommandArgument.fromProps({
825+ description: 'member index (starts with 0), name, or avatar',
826+ typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING],
827+ isRequired: true,
828+ enumProvider: commonEnumProviders.groupMembers(),
829+ }),
830+ ],
831+ helpString: 'Retrieves a group member\'s name, index, id, or avatar.',
832+ }));
833+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
785834 name: 'member-disable',
786835 callback: disableGroupMemberCallback,
787836 aliases: ['disable', 'disablemember', 'memberdisable'],
@@ -891,7 +940,8 @@ export function initDefaultSlashCommands() {
891940 helpString: 'Moves a group member down in the group chat list.',
892941 }));
893942 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
894943 name: 'member-peek',
944+ aliases: ['peek', 'memberpeek', 'peekmember'],
895945 callback: peekCallback,
896946 unnamedArgumentList: [
897947 SlashCommandArgument.fromProps({
@@ -1057,7 +1107,6 @@ export function initDefaultSlashCommands() {
10571107 typeList: [ARGUMENT_TYPE.STRING],
10581108 defaultValue: 'System',
10591109 enumProvider: () => [...commonEnumProviders.characters('character')(), new SlashCommandEnumValue('System', null, enumTypes.enum, enumIcons.assistant)],
1060- forceEnum: false,
10611110 }),
10621111 new SlashCommandNamedArgument(
10631112 'length', 'API response length in tokens', [ARGUMENT_TYPE.NUMBER], false,
@@ -1951,7 +2000,7 @@ export function initDefaultSlashCommands() {
19512000 returns: 'uppercase string',
19522001 unnamedArgumentList: [
19532002 new SlashCommandArgument(
19542003 'stringtext to affect', [ARGUMENT_TYPE.STRING], true, false,
19552004 ),
19562005 ],
19572006 helpString: 'Converts the provided string to uppercase.',
@@ -1963,7 +2012,7 @@ export function initDefaultSlashCommands() {
19632012 returns: 'lowercase string',
19642013 unnamedArgumentList: [
19652014 new SlashCommandArgument(
19662015 'stringtext to affect', [ARGUMENT_TYPE.STRING], true, false,
19672016 ),
19682017 ],
19692018 helpString: 'Converts the provided string to lowercase.',
@@ -1983,7 +2032,7 @@ export function initDefaultSlashCommands() {
19832032 ],
19842033 unnamedArgumentList: [
19852034 new SlashCommandArgument(
19862035 'stringtext to affect', [ARGUMENT_TYPE.STRING], true, false,
19872036 ),
19882037 ],
19892038 helpString: `
@@ -2047,6 +2096,62 @@ export function initDefaultSlashCommands() {
20472096 return '';
20482097 },
20492098 }));
2099+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2100+ name: 'replace',
2101+ aliases: ['re'],
2102+ callback: (async ({ mode = 'literal', pattern, replacer = '' }, text) => {
2103+ if (pattern === '')
2104+ throw new Error('Argument of \'pattern=\' cannot be empty');
2105+ switch (mode) {
2106+ case 'literal':
2107+ return text.replaceAll(pattern, replacer);
2108+ case 'regex':
2109+ return text.replace(regexFromString(pattern), replacer);
2110+ default:
2111+ throw new Error('Invalid \'/replace mode=\' argument specified!');
2112+ }
2113+ }),
2114+ returns: 'replaced text',
2115+ namedArgumentList: [
2116+ SlashCommandNamedArgument.fromProps({
2117+ name: 'mode',
2118+ description: 'Replaces occurrence(s) of a pattern',
2119+ typeList: [ARGUMENT_TYPE.STRING],
2120+ defaultValue: 'literal',
2121+ enumList: ['literal', 'regex'],
2122+ }),
2123+ new SlashCommandNamedArgument(
2124+ 'pattern', 'pattern to search with', [ARGUMENT_TYPE.STRING], true, false,
2125+ ),
2126+ new SlashCommandNamedArgument(
2127+ 'replacer', 'replacement text for matches', [ARGUMENT_TYPE.STRING], false, false, '',
2128+ ),
2129+ ],
2130+ unnamedArgumentList: [
2131+ new SlashCommandArgument(
2132+ 'text to affect', [ARGUMENT_TYPE.STRING], true, false,
2133+ ),
2134+ ],
2135+ helpString: `
2136+ <div>
2137+ Replaces text within the provided string based on the pattern.
2138+ </div>
2139+ <div>
2140+ If <code>mode</code> is <code>literal</code> (or omitted), <code>pattern</code> is a literal search string (case-sensitive).<br />
2141+ If <code>mode</code> is <code>regex</code>, <code>pattern</code> is parsed as an ECMAScript Regular Expression.<br />
2142+ The <code>replacer</code> replaces based on the <code>pattern</code> in the input text.<br />
2143+ If <code>replacer</code> is omitted, the replacement(s) will be an empty string.<br />
2144+ </div>
2145+ <div>
2146+ <strong>Example:</strong>
2147+ <pre>/let x Blue house and blue car || </pre>
2148+ <pre>/replace pattern="blue" {{var::x}} | /echo |/# Blue house and car ||</pre>
2149+ <pre>/replace pattern="blue" replacer="red" {{var::x}} | /echo |/# Blue house and red car ||</pre>
2150+ <pre>/replace mode=regex pattern="/blue/i" replacer="red" {{var::x}} | /echo |/# red house and blue car ||</pre>
2151+ <pre>/replace mode=regex pattern="/blue/gi" replacer="red" {{var::x}} | /echo |/# red house and red car ||</pre>
2152+ </div>
2153+ `,
2154+ }));
20502155
20512156 registerVariableCommands();
20522157}
@@ -3039,7 +3144,7 @@ function performGroupMemberAction(chid, action) {
30393144
30403145async function disableGroupMemberCallback(_, arg) {
30413146 if (!selected_group) {
30423147 toastr.warning('Cannot run /member-disable command outside of a group chat.');
30433148 return '';
30443149 }
30453150
@@ -3056,7 +3161,7 @@ async function disableGroupMemberCallback(_, arg) {
30563161
30573162async function enableGroupMemberCallback(_, arg) {
30583163 if (!selected_group) {
30593164 toastr.warning('Cannot run /member-enable command outside of a group chat.');
30603165 return '';
30613166 }
30623167
@@ -3073,7 +3178,7 @@ async function enableGroupMemberCallback(_, arg) {
30733178
30743179async function moveGroupMemberUpCallback(_, arg) {
30753180 if (!selected_group) {
30763181 toastr.warning('Cannot run /memberupmember-up command outside of a group chat.');
30773182 return '';
30783183 }
30793184
@@ -3090,7 +3195,7 @@ async function moveGroupMemberUpCallback(_, arg) {
30903195
30913196async function moveGroupMemberDownCallback(_, arg) {
30923197 if (!selected_group) {
30933198 toastr.warning('Cannot run /memberdownmember-down command outside of a group chat.');
30943199 return '';
30953200 }
30963201
@@ -3107,12 +3212,12 @@ async function moveGroupMemberDownCallback(_, arg) {
31073212
31083213async function peekCallback(_, arg) {
31093214 if (!selected_group) {
31103215 toastr.warning('Cannot run /member-peek command outside of a group chat.');
31113216 return '';
31123217 }
31133218
31143219 if (is_group_generating) {
31153220 toastr.warning('Cannot run /member-peek command while the group reply is generating.');
31163221 return '';
31173222 }
31183223
@@ -3129,12 +3234,7 @@ async function peekCallback(_, arg) {
31293234
31303235async function removeGroupMemberCallback(_, arg) {
31313236 if (!selected_group) {
31323237 toastr.warning('Cannot run /memberremovemember-remove command outside of a group chat.');
3133- return '';
3134- }
3135-
3136- if (is_group_generating) {
3137- toastr.warning('Cannot run /memberremove command while the group reply is generating.');
31383238 return '';
31393239 }
31403240
@@ -3242,12 +3342,7 @@ function findPersonaByName(name) {
32423342}
32433343
32443344async function sendUserMessageCallback(args, text) {
3245- if (!text) {
3345+ text = String(text ?? '').trim();
3246- toastr.warning('You must specify text to send');
3247- return;
3248- }
3249-
3250- text = text.trim();
32513346 const compact = isTrueBoolean(args?.compact);
32523347 const bias = extractMessageBias(text);
32533348
@@ -3562,24 +3657,18 @@ export function getNameAndAvatarForMessage(character, name = null) {
35623657}
35633658
35643659export async function sendMessageAs(args, text) {
3565- if (!text) {
3566- toastr.warning('You must specify text to send as');
3567- return '';
3568- }
3569-
35703660 let name = args.name?.trim();
3571- let mesText;
35723661
35733662 if (!name) {
35743663 const namelessWarningKey = 'sendAsNamelessWarningShown';
35753664 if (localStorageaccountStorage.getItem(namelessWarningKey) !== 'true') {
35763665 toastr.warning('To avoid confusion, please use /sendas name="Character Name"', 'Name defaulted to {{char}}', { timeOut: 10000 });
35773666 localStorageaccountStorage.setItem(namelessWarningKey, 'true');
35783667 }
35793668 name = name2;
35803669 }
35813670
35823671 let mesText = String(text ?? '').trim();
35833672
35843673 // Requires a regex check after the slash command is pushed to output
35853674 mesText = getRegexedString(mesText, regex_placement.SLASH_COMMAND, { characterOverride: name });
@@ -3657,11 +3746,7 @@ export async function sendMessageAs(args, text) {
36573746}
36583747
36593748export async function sendNarratorMessage(args, text) {
3660- if (!text) {
3749+ text = String(text ?? '');
3661- toastr.warning('You must specify text to send');
3662- return '';
3663- }
3664-
36653750 const name = chat_metadata[NARRATOR_NAME_KEY] || NARRATOR_NAME_DEFAULT;
36663751 // Messages that do nothing but set bias will be hidden from the context
36673752 const bias = extractMessageBias(text);
@@ -3752,18 +3837,13 @@ export async function promptQuietForLoudResponse(who, text) {
37523837}
37533838
37543839async function sendCommentMessage(args, text) {
3755- if (!text) {
3756- toastr.warning('You must specify text to send');
3757- return '';
3758- }
3759-
37603840 const compact = isTrueBoolean(args?.compact);
37613841 const message = {
37623842 name: COMMENT_NAME_DEFAULT,
37633843 is_user: false,
37643844 is_system: true,
37653845 send_date: getMessageTimeStamp(),
37663846 mes: substituteParams(String(text ?? '').trim()),
37673847 force_avatar: comment_avatar,
37683848 extra: {
37693849 type: system_message_types.COMMENT,
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/st-context.js+18 -0
@@ -68,10 +68,14 @@ import { tag_map, tags } from './tags.js';
6868import { textgenerationwebui_settings } from './textgen-settings.js';
6969import { tokenizers, getTextTokens, getTokenCount, getTokenCountAsync, getTokenizerModel } from './tokenizers.js';
7070import { ToolManager } from './tool-calling.js';
71+import { accountStorage } from './util/AccountStorage.js';
7172import { timestampToMoment, uuidv4 } from './utils.js';
73+import { getGlobalVariable, getLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js';
74+import { convertCharacterBook, loadWorldInfo, saveWorldInfo, updateWorldInfoList } from './world-info.js';
7275
7376export function getContext() {
7477 return {
78+ accountStorage,
7579 chat,
7680 characters,
7781 groups,
@@ -175,6 +179,20 @@ export function getContext() {
175179 humanizedDateTime,
176180 updateMessageBlock,
177181 appendMediaToMessage,
182+ variables: {
183+ local: {
184+ get: getLocalVariable,
185+ set: setLocalVariable,
186+ },
187+ global: {
188+ get: getGlobalVariable,
189+ set: setGlobalVariable,
190+ },
191+ },
192+ loadWorldInfo,
193+ saveWorldInfo,
194+ updateWorldInfoList,
195+ convertCharacterBook,
178196 };
179197}
180198
public/scripts/templates/itemizationChat.html+1 -1
@@ -146,5 +146,5 @@
146146</div>
147147<hr>
148148<div id="rawPromptPopup" class="list-group">
149149 <div id="rawPromptWrapper" class="tokenItemizingSubclasstokenItemizingMaintext"></div>
150150</div>
public/scripts/textgen-models.js+15 -3
@@ -6,6 +6,7 @@ import { tokenizers } from './tokenizers.js';
66import { renderTemplateAsync } from './templates.js';
77import { POPUP_TYPE, callGenericPopup } from './popup.js';
88import { t } from './i18n.js';
9+import { accountStorage } from './util/AccountStorage.js';
910
1011let mancerModels = [];
1112let togetherModels = [];
@@ -54,6 +55,17 @@ const OPENROUTER_PROVIDERS = [
5455 'xAI',
5556 'Cloudflare',
5657 'SF Compute',
58+ 'Minimax',
59+ 'Nineteen',
60+ 'Liquid',
61+ 'InferenceNet',
62+ 'Friendli',
63+ 'AionLabs',
64+ 'Alibaba',
65+ 'Nebius',
66+ 'Chutes',
67+ 'Kluster',
68+ 'Targon',
5769 '01.AI',
5870 'HuggingFace',
5971 'Mancer',
@@ -330,7 +342,7 @@ export async function loadFeatherlessModels(data) {
330342 populateClassSelection(data);
331343
332344 // Retrieve the stored number of items per page or default to 10
333345 const perPage = Number(localStorageaccountStorage.getItem(storageKey)) || 10;
334346
335347 // Initialize pagination
336348 applyFiltersAndSort();
@@ -406,7 +418,7 @@ export async function loadFeatherlessModels(data) {
406418 },
407419 afterSizeSelectorChange: function (e) {
408420 const newPerPage = e.target.value;
409421 localStorageaccountStorage.setItem('Models_PerPage'storageKey, newPerPage);
410422 setupPagination(models, Number(newPerPage), featherlessCurrentPage); // Use the stored current page number
411423 },
412424 });
@@ -507,7 +519,7 @@ export async function loadFeatherlessModels(data) {
507519 const currentModelIndex = filteredModels.findIndex(x => x.id === textgen_settings.featherless_model);
508520 featherlessCurrentPage = currentModelIndex >= 0 ? (currentModelIndex / perPage) + 1 : 1;
509521
510522 setupPagination(filteredModels, Number(localStorageaccountStorage.getItem(storageKey)) || perPage, featherlessCurrentPage);
511523 }
512524
513525 // Required to keep the /model command function
public/scripts/textgen-settings.js+44 -9
@@ -10,6 +10,7 @@ import {
1010 setOnlineStatus,
1111 substituteParams,
1212} from '../script.js';
13+import { t } from './i18n.js';
1314import { BIAS_CACHE, createNewLogitBiasEntry, displayLogitBias, getLogitBiasListResult } from './logit-bias.js';
1415
1516import { power_user, registerDebugFunction } from './power-user.js';
@@ -172,6 +173,7 @@ const settings = {
172173 //truncation_length: 2048,
173174 ban_eos_token: false,
174175 skip_special_tokens: true,
176+ include_reasoning: true,
175177 streaming: false,
176178 mirostat_mode: 0,
177179 mirostat_tau: 5,
@@ -181,6 +183,8 @@ const settings = {
181183 grammar_string: '',
182184 json_schema: {},
183185 banned_tokens: '',
186+ global_banned_tokens: '',
187+ send_banned_tokens: true,
184188 sampler_priority: OOBA_DEFAULT_ORDER,
185189 samplers: LLAMACPP_DEFAULT_ORDER,
186190 samplers_priorities: APHRODITE_DEFAULT_ORDER,
@@ -263,6 +267,7 @@ export const setting_names = [
263267 'add_bos_token',
264268 'ban_eos_token',
265269 'skip_special_tokens',
270+ 'include_reasoning',
266271 'streaming',
267272 'mirostat_mode',
268273 'mirostat_tau',
@@ -272,6 +277,8 @@ export const setting_names = [
272277 'grammar_string',
273278 'json_schema',
274279 'banned_tokens',
280+ 'global_banned_tokens',
281+ 'send_banned_tokens',
275282 'ignore_eos_token',
276283 'spaces_between_special_tokens',
277284 'speculative_ngram',
@@ -304,7 +311,7 @@ export function validateTextGenUrl() {
304311 const formattedUrl = formatTextGenURL(url);
305312
306313 if (!formattedUrl) {
307314 toastr.error('t`Enter a valid API URL'`, 'Text Completion API');
308315 return;
309316 }
310317
@@ -392,7 +399,7 @@ function getTokenizerForTokenIds() {
392399 * @returns {TokenBanResult} String with comma-separated banned token IDs
393400 */
394401function getCustomTokenBans() {
395402 if (!settings.send_banned_tokens || (!settings.banned_tokens && !settings.global_banned_tokens && !textgenerationwebui_banned_in_macros.length)) {
396403 return {
397404 banned_tokens: '',
398405 banned_strings: [],
@@ -402,8 +409,9 @@ function getCustomTokenBans() {
402409 const tokenizer = getTokenizerForTokenIds();
403410 const banned_tokens = [];
404411 const banned_strings = [];
405412 const sequences = settings.banned_tokens[]
406413 .concat(settings.banned_tokens.split('\n'))
414+ .concat(settings.global_banned_tokens.split('\n'))
407415 .concat(textgenerationwebui_banned_in_macros)
408416 .filter(x => x.length > 0)
409417 .filter(onlyUnique);
@@ -451,6 +459,18 @@ function getCustomTokenBans() {
451459}
452460
453461/**
462+ * Sets the banned strings kill switch toggle.
463+ * @param {boolean} isEnabled Kill switch state
464+ * @param {string} title Label title
465+ */
466+function toggleBannedStringsKillSwitch(isEnabled, title) {
467+ $('#send_banned_tokens_textgenerationwebui').prop('checked', isEnabled);
468+ $('#send_banned_tokens_label').find('.menu_button').toggleClass('toggleEnabled', isEnabled).prop('title', title);
469+ settings.send_banned_tokens = isEnabled;
470+ saveSettingsDebounced();
471+}
472+
473+/**
454474 * Calculates logit bias object from the logit bias list.
455475 * @returns {object} Logit bias object
456476 */
@@ -501,7 +521,7 @@ export function loadTextGenSettings(data, loadedSettings) {
501521 for (const [type, selector] of Object.entries(SERVER_INPUTS)) {
502522 const control = $(selector);
503523 control.val(settings.server_urls[type] ?? '').on('input', function () {
504524 settings.server_urls[type] = String($(this).val()).trim();
505525 saveSettingsDebounced();
506526 });
507527 }
@@ -592,6 +612,14 @@ function sortAphroditeItemsByOrder(orderArray) {
592612}
593613
594614jQuery(function () {
615+ $('#send_banned_tokens_textgenerationwebui').on('change', function () {
616+ const checked = !!$(this).prop('checked');
617+ toggleBannedStringsKillSwitch(checked,
618+ checked
619+ ? t`Banned tokens/strings are being sent in the request.`
620+ : t`Banned tokens/strings are NOT being sent in the request.`);
621+ });
622+
595623 $('#koboldcpp_order').sortable({
596624 delay: getSortableDelay(),
597625 stop: function () {
@@ -740,6 +768,7 @@ jQuery(function () {
740768 'add_bos_token_textgenerationwebui': true,
741769 'temperature_last_textgenerationwebui': true,
742770 'skip_special_tokens_textgenerationwebui': true,
771+ 'include_reasoning_textgenerationwebui': true,
743772 'top_a_textgenerationwebui': 0,
744773 'top_a_counter_textgenerationwebui': 0,
745774 'mirostat_mode_textgenerationwebui': 0,
@@ -929,6 +958,10 @@ function setSettingByName(setting, value, trigger) {
929958 if (isCheckbox) {
930959 const val = Boolean(value);
931960 $(`#${setting}_textgenerationwebui`).prop('checked', val);
961+
962+ if ('send_banned_tokens' === setting) {
963+ $(`#${setting}_textgenerationwebui`).trigger('change');
964+ }
932965 }
933966 else if (isText) {
934967 $(`#${setting}_textgenerationwebui`).val(value);
@@ -986,7 +1019,7 @@ export async function generateTextGenWithStreaming(generate_data, signal) {
9861019 let logprobs = null;
9871020 const swipes = [];
9881021 const toolCalls = [];
9891022 const state = { reasoning: '' };
9901023 while (true) {
9911024 const { done, value } = await reader.read();
9921025 if (done) return;
@@ -1003,6 +1036,7 @@ export async function generateTextGenWithStreaming(generate_data, signal) {
10031036 const newText = data?.choices?.[0]?.text || data?.content || '';
10041037 text += newText;
10051038 logprobs = parseTextgenLogprobs(newText, data.choices?.[0]?.logprobs || data?.completion_probabilities);
1039+ state.reasoning += data?.choices?.[0]?.reasoning ?? '';
10061040 }
10071041
10081042 yield { text, swipes, logprobs, toolCalls, state };
@@ -1153,7 +1187,7 @@ export function getTextGenModel() {
11531187 return settings.aphrodite_model;
11541188 case OLLAMA:
11551189 if (!settings.ollama_model) {
11561190 toastr.error('t`No Ollama model selected.'`, 'Text Completion API');
11571191 throw new Error('No Ollama model selected');
11581192 }
11591193 return settings.ollama_model;
@@ -1217,7 +1251,7 @@ function replaceMacrosInList(str) {
12171251 }
12181252}
12191253
12201254export async function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) {
12211255 const canMultiSwipe = !isContinue && !isImpersonate && type !== 'quiet';
12221256 const dynatemp = isDynamicTemperatureSupported();
12231257 const { banned_tokens, banned_strings } = getCustomTokenBans();
@@ -1266,6 +1300,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
12661300 'truncation_length': max_context,
12671301 'ban_eos_token': settings.ban_eos_token,
12681302 'skip_special_tokens': settings.skip_special_tokens,
1303+ 'include_reasoning': settings.include_reasoning,
12691304 'top_a': settings.top_a,
12701305 'tfs': settings.tfs,
12711306 'epsilon_cutoff': [OOBA, MANCER].includes(settings.type) ? settings.epsilon_cutoff : undefined,
@@ -1444,7 +1479,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
14441479 }
14451480 }
14461481
14471482 await eventSource.emitAndWaitemit(event_types.TEXT_COMPLETION_SETTINGS_READY, params);
14481483
14491484 // Grammar conflicts with with json_schema
14501485 if (settings.type === LLAMACPP) {
public/scripts/tokenizers.js+6 -0
@@ -679,6 +679,9 @@ export function getTokenizerModel() {
679679 }
680680
681681 if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) {
682+ if (oai_settings.perplexity_model.includes('sonar-reasoning') || oai_settings.perplexity_model.includes('r1-1776')) {
683+ return deepseekTokenizer;
684+ }
682685 if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) {
683686 return llama3Tokenizer;
684687 }
@@ -691,6 +694,9 @@ export function getTokenizerModel() {
691694 }
692695
693696 if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) {
697+ if (oai_settings.groq_model.includes('qwen')) {
698+ return qwen2Tokenizer;
699+ }
694700 if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) {
695701 return llama3Tokenizer;
696702 }
public/scripts/tool-calling.js+1 -0
@@ -563,6 +563,7 @@ export class ToolManager {
563563 chat_completion_sources.OPENROUTER,
564564 chat_completion_sources.GROQ,
565565 chat_completion_sources.COHERE,
566+ chat_completion_sources.DEEPSEEK,
566567 ];
567568 return supportedSources.includes(oai_settings.chat_completion_source);
568569 }
public/scripts/user.js+34 -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
@@ -44,6 +47,14 @@ export function isAdmin() {
4447}
4548
4649/**
50+ * Gets the handle string of the current user.
51+ * @returns {string} User handle
52+ */
53+export function getCurrentUserHandle() {
54+ return currentUser?.handle || 'default-user';
55+}
56+
57+/**
4758 * Get the current user.
4859 * @returns {Promise<void>}
4960 */
@@ -886,6 +897,24 @@ async function slugify(text) {
886897 }
887898}
888899
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+
889918jQuery(() => {
890919 $('#logout_button').on('click', () => {
891920 logout();
@@ -896,4 +925,9 @@ jQuery(() => {
896925 $('#account_button').on('click', () => {
897926 openUserProfile();
898927 });
928+ setInterval(async () => {
929+ if (currentUser) {
930+ await extendUserSession();
931+ }
932+ }, SESSION_EXTEND_INTERVAL);
899933});
public/scripts/util/AccountStorage.js+139 -0
@@ -0,0 +1,139 @@
1+import { saveSettingsDebounced } from '../../script.js';
2+
3+const MIGRATED_MARKER = '__migrated';
4+const MIGRATABLE_KEYS = [
5+ /^AlertRegex_/,
6+ /^AlertWI_/,
7+ /^Assets_SkipConfirm_/,
8+ /^Characters_PerPage$/,
9+ /^DataBank_sortField$/,
10+ /^DataBank_sortOrder$/,
11+ /^extension_update_nag$/,
12+ /^extensions_sortByName$/,
13+ /^FeatherlessModels_PerPage$/,
14+ /^GroupMembers_PerPage$/,
15+ /^GroupCandidates_PerPage$/,
16+ /^LNavLockOn$/,
17+ /^LNavOpened$/,
18+ /^mediaWarningShown:/,
19+ /^NavLockOn$/,
20+ /^NavOpened$/,
21+ /^Personas_PerPage$/,
22+ /^Personas_GridView$/,
23+ /^Proxy_SkipConfirm_/,
24+ /^qr--executeShortcut$/,
25+ /^qr--syntax$/,
26+ /^qr--tabSize$/,
27+ /^qr--wrap$/,
28+ /^RegenerateWithCtrlEnter$/,
29+ /^SelectedNavTab$/,
30+ /^sendAsNamelessWarningShown$/,
31+ /^StoryStringValidationCache$/,
32+ /^WINavOpened$/,
33+ /^WI_PerPage$/,
34+ /^world_info_sort_order$/,
35+];
36+
37+/**
38+ * Provides access to account storage of arbitrary key-value pairs.
39+ */
40+class AccountStorage {
41+ /**
42+ * @type {Record<string, string>} Storage state
43+ */
44+ #state = {};
45+
46+ /**
47+ * @type {boolean} If the storage was initialized
48+ */
49+ #ready = false;
50+
51+ #migrateLocalStorage() {
52+ const localStorageKeys = [];
53+ for (let i = 0; i < globalThis.localStorage.length; i++) {
54+ localStorageKeys.push(globalThis.localStorage.key(i));
55+ }
56+ for (const key of localStorageKeys) {
57+ if (MIGRATABLE_KEYS.some(k => k.test(key))) {
58+ const value = globalThis.localStorage.getItem(key);
59+ this.#state[key] = value;
60+ globalThis.localStorage.removeItem(key);
61+ }
62+ }
63+ }
64+
65+ /**
66+ * Initialize the account storage.
67+ * @param {Object} state Initial state
68+ */
69+ init(state) {
70+ if (state && typeof state === 'object') {
71+ this.#state = Object.assign(this.#state, state);
72+ }
73+
74+ if (!Object.hasOwn(this.#state, MIGRATED_MARKER)) {
75+ this.#migrateLocalStorage();
76+ this.#state[MIGRATED_MARKER] = '1';
77+ saveSettingsDebounced();
78+ }
79+
80+ this.#ready = true;
81+ }
82+
83+ /**
84+ * Get the value of a key in account storage.
85+ * @param {string} key Key to get
86+ * @returns {string|null} Value of the key
87+ */
88+ getItem(key) {
89+ if (!this.#ready) {
90+ console.warn(`AccountStorage not ready (trying to read from ${key})`);
91+ }
92+
93+ return Object.hasOwn(this.#state, key) ? String(this.#state[key]) : null;
94+ }
95+
96+ /**
97+ * Set a key in account storage.
98+ * @param {string} key Key to set
99+ * @param {string} value Value to set
100+ */
101+ setItem(key, value) {
102+ if (!this.#ready) {
103+ console.warn(`AccountStorage not ready (trying to write to ${key})`);
104+ }
105+
106+ this.#state[key] = String(value);
107+ saveSettingsDebounced();
108+ }
109+
110+ /**
111+ * Remove a key from account storage.
112+ * @param {string} key Key to remove
113+ */
114+ removeItem(key) {
115+ if (!this.#ready) {
116+ console.warn(`AccountStorage not ready (trying to remove ${key})`);
117+ }
118+
119+ if (!Object.hasOwn(this.#state, key)) {
120+ return;
121+ }
122+
123+ delete this.#state[key];
124+ saveSettingsDebounced();
125+ }
126+
127+ /**
128+ * Gets a snapshot of the storage state.
129+ * @returns {Record<string, string>} A deep clone of the storage state
130+ */
131+ getState() {
132+ return structuredClone(this.#state);
133+ }
134+}
135+
136+/**
137+ * Account storage instance.
138+ */
139+export const accountStorage = new AccountStorage();
public/scripts/utils.js+48 -13
@@ -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/**
@@ -1733,17 +1751,17 @@ export function hasAnimation(control) {
17331751
17341752/**
17351753 * Run an action once an animation on a control ends. If the control has no animation, the action will be executed immediately.
1736- *
1754+ * The action will be executed after the animation ends or after the timeout, whichever comes first.
17371755 * @param {HTMLElement} control - The control element to listen for animation end event
17381756 * @param {(control:*?) => void} callback - The callback function to be executed when the animation ends
1757+ * @param {number} [timeout=500] - The timeout in milliseconds to wait for the animation to end before executing the callback
17391758 */
17401759export function runAfterAnimation(control, callback, timeout = 500) {
17411760 if (hasAnimation(control)) {
1742- const onAnimationEnd = () => {
1761+ Promise.race([
1743- control.removeEventListener('animationend', onAnimationEnd);
1762+ new Promise((r) => setTimeout(r, timeout)), // Fallback timeout
1744- callback(control);
1763+ new Promise((r) => control.addEventListener('animationend', r, { once: true })),
1745- };
1764+ ]).finally(() => callback(control));
1746- control.addEventListener('animationend', onAnimationEnd);
17471765 } else {
17481766 callback(control);
17491767 }
@@ -2059,6 +2077,23 @@ export function toggleDrawer(drawer, expand = true) {
20592077 }
20602078}
20612079
2080+/**
2081+ * Sets or removes a dataset property on an HTMLElement
2082+ *
2083+ * Utility function to make it easier to reset dataset properties on null, without them being "null" as value.
2084+ *
2085+ * @param {HTMLElement} element - The element to modify
2086+ * @param {string} name - The name of the dataset property
2087+ * @param {string|null} value - The value to set - If null, the dataset property will be removed
2088+ */
2089+export function setDatasetProperty(element, name, value) {
2090+ if (value === null) {
2091+ delete element.dataset[name];
2092+ } else {
2093+ element.dataset[name] = value;
2094+ }
2095+}
2096+
20622097export async function fetchFaFile(name) {
20632098 const style = document.createElement('style');
20642099 style.innerHTML = await (await fetch(`/css/${name}`)).text();
public/scripts/variables.js+4 -4
@@ -19,7 +19,7 @@ import { isFalseBoolean, convertValueType, isTrueBoolean } from './utils.js';
1919
2020const MAX_LOOPS = 100;
2121
2222export function getLocalVariable(name, args = {}) {
2323 if (!chat_metadata.variables) {
2424 chat_metadata.variables = {};
2525 }
@@ -45,7 +45,7 @@ function getLocalVariable(name, args = {}) {
4545 return (localVariable?.trim?.() === '' || isNaN(Number(localVariable))) ? (localVariable || '') : Number(localVariable);
4646}
4747
4848export function setLocalVariable(name, value, args = {}) {
4949 if (!name) {
5050 throw new Error('Variable name cannot be empty or undefined.');
5151 }
@@ -80,7 +80,7 @@ function setLocalVariable(name, value, args = {}) {
8080 return value;
8181}
8282
8383export function getGlobalVariable(name, args = {}) {
8484 let globalVariable = extension_settings.variables.global[args.key ?? name];
8585 if (args.index !== undefined) {
8686 try {
@@ -102,7 +102,7 @@ function getGlobalVariable(name, args = {}) {
102102 return (globalVariable?.trim?.() === '' || isNaN(Number(globalVariable))) ? (globalVariable || '') : Number(globalVariable);
103103}
104104
105105export function setGlobalVariable(name, value, args = {}) {
106106 if (!name) {
107107 throw new Error('Variable name cannot be empty or undefined.');
108108 }
public/scripts/world-info.js+36 -18
@@ -21,6 +21,7 @@ import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
2121import { StructuredCloneMap } from './util/StructuredCloneMap.js';
2222import { renderTemplateAsync } from './templates.js';
2323import { t } from './i18n.js';
24+import { accountStorage } from './util/AccountStorage.js';
2425
2526export const world_info_insertion_strategy = {
2627 evenly: 0,
@@ -400,6 +401,12 @@ class WorldInfoTimedEffects {
400401 #entries = [];
401402
402403 /**
404+ * Is this a dry run?
405+ * @type {boolean}
406+ */
407+ #isDryRun = false;
408+
409+ /**
403410 * Buffer for active timed effects.
404411 * @type {Record<TimedEffectType, WIScanEntry[]>}
405412 */
@@ -448,10 +455,12 @@ class WorldInfoTimedEffects {
448455 * Initialize the timed effects with the given messages.
449456 * @param {string[]} chat Array of chat messages
450457 * @param {WIScanEntry[]} entries Array of entries
458+ * @param {boolean} isDryRun Whether the operation is a dry run
451459 */
452460 constructor(chat, entries, isDryRun = false) {
453461 this.#chat = chat;
454462 this.#entries = entries;
463+ this.#isDryRun = isDryRun;
455464 this.#ensureChatMetadata();
456465 }
457466
@@ -583,8 +592,10 @@ class WorldInfoTimedEffects {
583592 * Checks for timed effects on chat messages.
584593 */
585594 checkTimedEffects() {
595+ if (!this.#isDryRun) {
586596 this.#checkTimedEffectOfType('sticky', this.#buffer.sticky, this.#onEnded.sticky.bind(this));
587597 this.#checkTimedEffectOfType('cooldown', this.#buffer.cooldown, this.#onEnded.cooldown.bind(this));
598+ }
588599 this.#checkDelayEffect(this.#buffer.delay);
589600 }
590601
@@ -629,6 +640,7 @@ class WorldInfoTimedEffects {
629640 * @param {WIScanEntry[]} activatedEntries Entries that were activated
630641 */
631642 setTimedEffects(activatedEntries) {
643+ if (this.#isDryRun) return;
632644 for (const entry of activatedEntries) {
633645 this.#setTimedEffectOfType('sticky', entry);
634646 this.#setTimedEffectOfType('cooldown', entry);
@@ -645,6 +657,9 @@ class WorldInfoTimedEffects {
645657 if (!this.isValidEffectType(type)) {
646658 return;
647659 }
660+ if (this.#isDryRun && type !== 'delay') {
661+ return;
662+ }
648663
649664 const key = this.#getEntryKey(entry);
650665 delete chat_metadata.timedWorldInfo[type][key];
@@ -858,7 +873,7 @@ export function setWorldInfoSettings(settings, data) {
858873 $('#world_editor_select').append(`<option value='${i}'>${item}</option>`);
859874 });
860875
861876 $('#world_info_sort_order').val(localStorageaccountStorage.getItem(SORT_ORDER_KEY) || '0');
862877 $('#world_info').trigger('change');
863878 $('#world_editor_select').trigger('change');
864879
@@ -1708,7 +1723,7 @@ export async function loadWorldInfo(name) {
17081723 return null;
17091724}
17101725
17111726export async function updateWorldInfoList() {
17121727 const result = await fetch('/api/settings/get', {
17131728 method: 'POST',
17141729 headers: getRequestHeaders(),
@@ -1933,13 +1948,13 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl
19331948 if (typeof navigation === 'number' && Number(navigation) >= 0) {
19341949 const data = getDataArray();
19351950 const uidIndex = data.findIndex(x => x.uid === navigation);
19361951 const perPage = Number(localStorageaccountStorage.getItem(storageKey)) || perPageDefault;
19371952 startPage = Math.floor(uidIndex / perPage) + 1;
19381953 }
19391954
19401955 $('#world_info_pagination').pagination({
19411956 dataSource: getDataArray,
19421957 pageSize: Number(localStorageaccountStorage.getItem(storageKey)) || perPageDefault,
19431958 sizeChangerOptions: [10, 25, 50, 100, 500, 1000],
19441959 showSizeChanger: true,
19451960 pageRange: 1,
@@ -1969,7 +1984,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl
19691984 worldEntriesList.append(blocks);
19701985 },
19711986 afterSizeSelectorChange: function (e) {
19721987 localStorageaccountStorage.setItem(storageKey, e.target.value);
19731988 },
19741989 afterPaging: function () {
19751990 $('#world_popup_entries_list textarea[name="comment"]').each(function () {
@@ -2174,7 +2189,7 @@ function verifyWorldInfoSearchSortRule() {
21742189 // If search got cleared, we make sure to hide the option and go back to the one before
21752190 if (!searchTerm && !isHidden) {
21762191 searchOption.attr('hidden', '');
21772192 selector.val(localStorageaccountStorage.getItem(SORT_ORDER_KEY) || '0');
21782193 }
21792194}
21802195
@@ -2423,7 +2438,9 @@ export async function getWorldEntry(name, data, entry) {
24232438 setWIOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
24242439 await saveWorldInfo(name, data);
24252440 }
2441+ $(this).toggleClass('empty', !data.entries[uid][entryPropName].length);
24262442 });
2443+ input.toggleClass('empty', !entry[entryPropName].length);
24272444 input.on('select2:select', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data]));
24282445 input.on('select2:unselect', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data], { remove: true }));
24292446
@@ -2458,6 +2475,7 @@ export async function getWorldEntry(name, data, entry) {
24582475 data.entries[uid][entryPropName] = splitKeywordsAndRegexes(value);
24592476 setWIOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
24602477 await saveWorldInfo(name, data);
2478+ $(this).toggleClass('empty', !data.entries[uid][entryPropName].length);
24612479 }
24622480 });
24632481 input.val(entry[entryPropName].join(', ')).trigger('input', { skipReset: true });
@@ -3435,7 +3453,7 @@ async function _save(name, data) {
34353453 headers: getRequestHeaders(),
34363454 body: JSON.stringify({ name: name, data: data }),
34373455 });
34383456 await eventSource.emit(event_types.WORLDINFO_UPDATED, name, data);
34393457}
34403458
34413459
@@ -3847,7 +3865,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun) {
38473865 const context = getContext();
38483866 const buffer = new WorldInfoBuffer(chat);
38493867
38503868 console.debug(`[WI] --- START WI SCAN (on ${chat.length} messages)${isDryRun ? ' (DRY RUN)' : ''} ---`);
38513869
38523870 // Combine the chat
38533871
@@ -3879,9 +3897,9 @@ export async function checkWorldInfo(chat, maxContext, isDryRun) {
38793897
38803898 console.debug(`[WI] Context size: ${maxContext}; WI budget: ${budget} (max% = ${world_info_budget}%, cap = ${world_info_budget_cap})`);
38813899 const sortedEntries = await getSortedEntries();
38823900 const timedEffects = new WorldInfoTimedEffects(chat, sortedEntries, isDryRun);
38833901
38843902 !isDryRun && timedEffects.checkTimedEffects();
38853903
38863904 if (sortedEntries.length === 0) {
38873905 return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], allActivatedEntries: new Set() };
@@ -4324,12 +4342,12 @@ export async function checkWorldInfo(chat, maxContext, isDryRun) {
43244342 context.setExtensionPrompt(NOTE_MODULE_NAME, ANWithWI, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]);
43254343 }
43264344
43274345 !isDryRun && timedEffects.setTimedEffects(Array.from(allActivatedEntries.values()));
43284346 buffer.resetExternalEffects();
43294347 timedEffects.cleanUp();
43304348
43314349 console.log(`[WI] ${isDryRun ? 'Hypothetically adding' : 'Adding'} ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values()));
43324350 console.debug('`[WI] --- DONE${isDryRun ? ' (DRY RUN)' : ''} ---'`);
43334351
43344352 return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, allActivatedEntries: new Set(allActivatedEntries.values()) };
43354353}
@@ -4658,7 +4676,7 @@ function convertNovelLorebook(inputObj) {
46584676 return outputObj;
46594677}
46604678
46614679export function convertCharacterBook(characterBook) {
46624680 const result = { entries: {}, originalData: characterBook };
46634681
46644682 characterBook.entries.forEach((entry, index) => {
@@ -4736,8 +4754,8 @@ export function checkEmbeddedWorld(chid) {
47364754 // Only show the alert once per character
47374755 const checkKey = `AlertWI_${characters[chid].avatar}`;
47384756 const worldName = characters[chid]?.data?.extensions?.world;
47394757 if (!localStorageaccountStorage.getItem(checkKey) && (!worldName || !world_names.includes(worldName))) {
47404758 localStorageaccountStorage.setItem(checkKey, 'true');
47414759
47424760 if (power_user.world_import_dialog) {
47434761 const html = `<h3>This character has an embedded World/Lorebook.</h3>
@@ -5181,7 +5199,7 @@ jQuery(() => {
51815199 $('#world_info_sort_order').on('change', function () {
51825200 const value = String($(this).find(':selected').val());
51835201 // Save sort order, but do not save search sorting, as this is a temporary sorting option
51845202 if (value !== 'search') localStorageaccountStorage.setItem(SORT_ORDER_KEY, value);
51855203 updateEditor(navigation_option.none);
51865204 });
51875205
public/style.css+144 -56
@@ -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);
@@ -106,6 +110,8 @@
106110 --tool-cool-color-picker-btn-bg: transparent;
107111 --tool-cool-color-picker-btn-border-color: transparent;
108112
113+ --mes-right-spacing: 30px;
114+
109115 --avatar-base-height: 50px;
110116 --avatar-base-width: 50px;
111117 --avatar-base-border-radius: 2px;
@@ -291,6 +297,10 @@ input[type='checkbox']:focus-visible {
291297 color: var(--SmartThemeEmColor);
292298}
293299
300+.tokenItemizingMaintext {
301+ font-size: calc(var(--mainFontSize) * 0.8);
302+}
303+
294304.tokenGraph {
295305 border-radius: 10px;
296306 border: 1px solid var(--SmartThemeBorderColor);
@@ -373,18 +383,56 @@ input[type='checkbox']:focus-visible {
373383
374384.mes_reasoning {
375385 display: block;
376386 border-left: 1px2px solid var(--SmartThemeBorderColorreasoning-body-color);
377- background-color: var(--black30a);
387+ border-radius: 2px;
378- border-radius: 5px;
379388 padding: 5px;
380389 marginpadding-left: 5px 014px;
390+ margin-bottom: 0.5em;
381391 overflow-y: auto;
392+ color: hsl(from var(--reasoning-body-color) h calc(s * var(--reasoning-saturation)) l);
393+}
394+
395+.mes_reasoning_details {
396+ margin-right: var(--mes-right-spacing);
382397}
383398
384399.mes_reasoning_details .mes_reasoning_summary {
400+ list-style: none;
401+ margin-right: calc(var(--mes-right-spacing) * -1);
402+}
403+
404+.mes_reasoning_details summary::-webkit-details-marker {
405+ display: none;
406+}
407+
408+.mes_reasoning *:last-child {
409+ margin-bottom: 0;
410+}
411+
412+.mes_reasoning_header_block {
413+ flex-grow: 1;
414+}
415+
416+.mes_reasoning_header {
385417 cursor: pointer;
386418 position: relative;
387419 marginuser-select: 2pxnone;
420+ margin: 0.5em 2px;
421+ padding: 7px 14px;
422+ padding-right: calc(0.7em + 14px);
423+ border-radius: 5px;
424+ background-color: var(--grey30);
425+ font-size: calc(var(--mainFontSize) * 0.9);
426+ align-items: baseline;
427+}
428+
429+.mes:has(.mes_reasoning:empty) .mes_reasoning_header {
430+ cursor: default;
431+}
432+
433+/* TWIMC: Remove with custom CSS to show the icon */
434+.mes_reasoning_header>.icon-svg {
435+ display: none;
388436}
389437
390438@supports not selector(:has(*)) {
@@ -394,29 +442,41 @@ input[type='checkbox']:focus-visible {
394442}
395443
396444.mes_bias:empty,
397-.mes_reasoning:empty,
445+.mes:not(.reasoning) .mes_reasoning_details,
398-.mes_reasoning_details:has(.mes_reasoning:empty),
399-.mes_block:has(.edit_textarea) .mes_reasoning_details,
400446.mes_reasoning_details:not([open]) .mes_reasoning_actions,
401447.mes_reasoning_details:has(.reasoning_edit_textarea) .mes_reasoning,
402448.mes_reasoning_details:not(:has(.reasoning_edit_textarea)) .mes_reasoning_actions .mes_button.mes_reasoning_edit_donemes_reasoning_header,
403449.mes_reasoning_details:not(:has(.reasoning_edit_textarea)) .mes_reasoning_actions .mes_button:not(.mes_reasoning_edit_canceledit_button),
404450.mes_reasoning_details:not(:has(.reasoning_edit_textarea)) .mes_reasoning_actions .mes_button:not(.mes_reasoning_edit_doneedit_button, .mes_reasoning_edit_cancel) {
451+.mes_block:has(.edit_textarea):has(.reasoning_edit_textarea) .mes_reasoning_actions,
452+.mes.reasoning:not([data-reasoning-state="hidden"]) .mes_edit_add_reasoning,
453+.mes:has(.mes_reasoning:empty) .mes_reasoning_arrow,
454+.mes:has(.mes_reasoning:empty) .mes_reasoning,
455+.mes:has(.mes_reasoning:empty) .mes_reasoning_copy {
405456 display: none;
406457}
407458
408-.mes_reasoning_actions {
459+.mes[data-reasoning-state="hidden"] .mes_edit_add_reasoning {
460+ background-color: color-mix(in srgb, var(--SmartThemeQuoteColor) 33%, var(--SmartThemeBlurTintColor) 66%);
461+}
462+
463+/** If hidden reasoning should not be shown, we hide all blocks that don't have content */
464+#chat:not([data-show-hidden-reasoning="true"]):not(:has(.reasoning_edit_textarea)) .mes:has(.mes_reasoning:empty) .mes_reasoning_details {
465+ display: none;
466+}
467+
468+.mes_reasoning_details .mes_reasoning_arrow {
409469 position: absolute;
410470 righttop: 050%;
411471 topright: 07px;
472+ transform: translateY(-50%);
473+ font-size: calc(var(--mainFontSize) * 0.7);
474+ width: calc(var(--mainFontSize) * 0.7);
475+ height: calc(var(--mainFontSize) * 0.7);
476+}
412477
413- display: flex;
478+.mes_reasoning_details:not([open]) .mes_reasoning_arrow {
414- gap: 4px;
479+ transform: translateY(-50%) rotate(180deg);
415- flex-wrap: nowrap;
416- justify-content: flex-end;
417- transition: all 200ms;
418- overflow-x: hidden;
419- padding: 1px;
420480}
421481
422482.mes_reasoning_summary>span {
@@ -424,21 +484,36 @@ input[type='checkbox']:focus-visible {
424484}
425485
426486.mes_text i,
427487.mes_text em, {
488+ color: var(--SmartThemeEmColor);
489+}
428490.mes_reasoning i,
429491.mes_reasoning em {
430- color: var(--SmartThemeEmColor);
492+ color: hsl(from var(--reasoning-em-color) h calc(s * var(--reasoning-saturation)) l);
431493}
432494
433495.mes_text uq i,
434496.mes_reasoningmes_text uq em {
497+ color: inherit;
498+}
499+.mes_reasoning q i,
500+.mes_reasoning q em {
501+ color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
502+}
503+
504+.mes_text u {
435505 color: var(--SmartThemeUnderlineColor);
436506}
507+.mes_reasoning u {
508+ color: hsl(from var(--SmartThemeUnderlineColor) h calc(s * var(--reasoning-saturation)) l);
509+}
437510
438511.mes_text q, {
439-.mes_reasoning q {
440512 color: var(--SmartThemeQuoteColor);
441513}
514+.mes_reasoning q {
515+ color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
516+}
442517
443518.mes_text font[color] em,
444519.mes_text font[color] i,
@@ -1126,13 +1201,8 @@ body .panelControlBar {
11261201 /*only affects bubblechat to make it sit nicely at the bottom*/
11271202}
11281203
11291204.last_mes:has(.mes_text:empty):has(.mes_reasoning_details[open]) .mes_reasoning:not(:empty) {
11301205 margin-bottom: 30pxvar(--mes-right-spacing);
1131-}
1132-
1133-.last_mes .mes_reasoning,
1134-.last_mes .mes_text {
1135- padding-right: 30px;
11361206}
11371207
11381208/* SWIPE RELATED STYLES*/
@@ -1363,6 +1433,7 @@ body.swipeAllMessages .mes:not(.last_mes) .swipes-counter {
13631433 padding-left: 0;
13641434 padding-top: 5px;
13651435 padding-bottom: 5px;
1436+ padding-right: var(--mes-right-spacing);
13661437}
13671438
13681439br {
@@ -2849,9 +2920,8 @@ select option:not(:checked) {
28492920 color: var(--active) !important;
28502921}
28512922
28522923#instruct_enabled_label .menu_button.togglable:not(.toggleEnabled), {
2853-#sysprompt_enabled_label .menu_button:not(.toggleEnabled) {
2924+ color: red;
2854- color: Red;
28552925}
28562926
28572927.displayBlock {
@@ -3048,6 +3118,7 @@ input[type=search]:focus::-webkit-search-cancel-button {
30483118.mes_block .ch_name {
30493119 max-width: 100%;
30503120 min-height: 22px;
3121+ align-items: flex-start;
30513122}
30523123
30533124/*applies to both groups and solos chars in the char list*/
@@ -4275,7 +4346,13 @@ input[type="range"]::-webkit-slider-thumb {
42754346 transition: 0.3s ease-in-out;
42764347}
42774348
42784349.mes_edit_buttons .menu_buttonmes_reasoning_actions {
4350+ margin: 0;
4351+ margin-top: 0.5em;
4352+}
4353+
4354+.mes_edit_buttons .menu_button,
4355+.mes_reasoning_actions .edit_button {
42794356 opacity: 0.5;
42804357 padding: 0px;
42814358 font-size: 1rem;
@@ -4288,6 +4365,12 @@ input[type="range"]::-webkit-slider-thumb {
42884365 align-items: center;
42894366}
42904367
4368+.mes_reasoning_actions .edit_button {
4369+ margin-bottom: 0.5em;
4370+ opacity: 1;
4371+ filter: brightness(0.7);
4372+}
4373+
42914374.mes_reasoning_edit_cancel,
42924375.mes_edit_cancel.menu_button {
42934376 background-color: var(--crimson70a);
@@ -4314,6 +4397,14 @@ input[type="range"]::-webkit-slider-thumb {
43144397 field-sizing: content;
43154398}
43164399
4400+body[data-generating="true"] #send_but,
4401+body[data-generating="true"] #mes_continue,
4402+body[data-generating="true"] #mes_impersonate,
4403+body[data-generating="true"] #chat .last_mes .mes_buttons,
4404+body[data-generating="true"] #chat .last_mes .mes_reasoning_actions {
4405+ display: none;
4406+}
4407+
43174408#anchor_order {
43184409 margin-bottom: 15px;
43194410}
@@ -4653,23 +4744,6 @@ body .ui-widget-content li:hover {
46534744 opacity: 1;
46544745}
46554746
4656-.typing_indicator {
4657- position: sticky;
4658- bottom: 10px;
4659- margin: 10px;
4660- opacity: 0.85;
4661- text-shadow: 0px 0px calc(var(--shadowWidth) * 1px) var(--SmartThemeShadowColor);
4662- order: 9999;
4663-}
4664-
4665-.typing_indicator:after {
4666- display: inline-block;
4667- vertical-align: bottom;
4668- animation: ellipsis steps(4, end) 1500ms infinite;
4669- content: "";
4670- width: 0px;
4671-}
4672-
46734747#group_avatar_preview .missing-avatar {
46744748 display: inline;
46754749 vertical-align: middle;
@@ -5758,11 +5832,13 @@ body:not(.movingUI) .drawer-content.maximized {
57585832 overflow-wrap: anywhere;
57595833}
57605834
5835+#SystemPromptColumn summary,
57615836#InstructSequencesColumn summary {
57625837 font-size: 0.95em;
57635838 cursor: pointer;
57645839}
57655840
5841+#SystemPromptColumn details,
57665842#InstructSequencesColumn details:not(:last-of-type) {
57675843 margin-bottom: 5px;
57685844}
@@ -5927,6 +6003,18 @@ body:not(.movingUI) .drawer-content.maximized {
59276003 flex: 1;
59286004}
59296005
6006+.oneline-dropdown label {
6007+ margin-top: 3px;
6008+ margin-bottom: 5px;
6009+ flex-grow: 1;
6010+ text-align: left;
6011+}
6012+
6013+.oneline-dropdown select {
6014+ min-width: fit-content;
6015+ width: 40%;
6016+}
6017+
59306018.multiline {
59316019 white-space: pre-wrap;
59326020}
server.js+133 -18
@@ -4,6 +4,7 @@
44import fs from 'node:fs';
55import http from 'node:http';
66import https from 'node:https';
7+import os from 'os';
78import path from 'node:path';
89import util from 'node:util';
910import net from 'node:net';
@@ -29,6 +30,7 @@ import bodyParser from 'body-parser';
2930
3031// net related library imports
3132import fetch from 'node-fetch';
33+import ipRegex from 'ip-regex';
3234
3335// Unrestrict console logs display limit
3436util.inspect.defaultOptions.maxArrayLength = null;
@@ -56,8 +58,10 @@ import {
5658import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
5759import basicAuthMiddleware from './src/middleware/basicAuth.js';
5860import whitelistMiddleware from './src/middleware/whitelist.js';
61+import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
5962import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
6063import initRequestProxy from './src/request-proxy.js';
64+import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
6165import {
6266 getVersion,
6367 getConfigValue,
@@ -65,7 +69,11 @@ import {
6569 forwardFetchResponse,
6670 removeColorFormatting,
6771 getSeparator,
72+ stringToBool,
73+ urlHostnameToIPv6,
74+ canResolve,
6875 safeReadFileSync,
76+ setupLogLevel,
6977} from './src/util.js';
7078import { UPLOADS_DIRECTORY } from './src/constants.js';
7179import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
@@ -125,6 +133,8 @@ if (process.versions && process.versions.node && process.versions.node.match(/20
125133const DEFAULT_PORT = 8000;
126134const DEFAULT_AUTORUN = false;
127135const DEFAULT_LISTEN = false;
136+const DEFAULT_LISTEN_ADDRESS_IPV6 = '[::]';
137+const DEFAULT_LISTEN_ADDRESS_IPV4 = '0.0.0.0';
128138const DEFAULT_CORS_PROXY = false;
129139const DEFAULT_WHITELIST = true;
130140const DEFAULT_ACCOUNTS = false;
@@ -149,11 +159,11 @@ const DEFAULT_PROXY_BYPASS = [];
149159const cliArguments = yargs(hideBin(process.argv))
150160 .usage('Usage: <your-start-script> <command> [options]')
151161 .option('enableIPv6', {
152162 type: 'booleanstring',
153163 default: null,
154164 describe: `Enables IPv6.\n[config default: ${DEFAULT_ENABLE_IPV6}]`,
155165 }).option('enableIPv4', {
156166 type: 'booleanstring',
157167 default: null,
158168 describe: `Enables IPv4.\n[config default: ${DEFAULT_ENABLE_IPV4}]`,
159169 }).option('port', {
@@ -180,6 +190,14 @@ const cliArguments = yargs(hideBin(process.argv))
180190 type: 'boolean',
181191 default: null,
182192 describe: `SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If false, will limit it only to internal localhost (127.0.0.1).\nIf not provided falls back to yaml config 'listen'.\n[config default: ${DEFAULT_LISTEN}]`,
193+ }).option('listenAddressIPv6', {
194+ type: 'string',
195+ default: null,
196+ describe: 'Set SillyTavern to listen to a specific IPv6 address. If not set, it will fallback to listen to all.\n[config default: [::] ]',
197+ }).option('listenAddressIPv4', {
198+ type: 'string',
199+ default: null,
200+ describe: 'Set SillyTavern to listen to a specific IPv4 address. If not set, it will fallback to listen to all.\n[config default: 0.0.0.0 ]',
183201 }).option('corsProxy', {
184202 type: 'boolean',
185203 default: null,
@@ -226,7 +244,6 @@ const cliArguments = yargs(hideBin(process.argv))
226244 describe: 'Request proxy URL (HTTP or SOCKS protocols)',
227245 }).option('requestProxyBypass', {
228246 type: 'array',
229- default: null,
230247 describe: 'Request proxy bypass list (space separated list of hosts)',
231248 }).parseSync();
232249
@@ -242,27 +259,46 @@ app.use(helmet({
242259app.use(compression());
243260app.use(responseTime());
244261
262+
263+/** @type {number} */
245264const server_port = cliArguments.port ?? process.env.SILLY_TAVERN_PORT ?? getConfigValue('port', DEFAULT_PORT);
265+/** @type {boolean} */
246266const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN)) && !cliArguments.ssl;
267+/** @type {boolean} */
247268const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN);
269+/** @type {string} */
270+const listenAddressIPv6 = cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', DEFAULT_LISTEN_ADDRESS_IPV6);
271+/** @type {string} */
272+const listenAddressIPv4 = cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', DEFAULT_LISTEN_ADDRESS_IPV4);
273+/** @type {boolean} */
248274const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY);
249275const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST);
276+/** @type {string} */
250277const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data');
278+/** @type {boolean} */
251279const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED);
252280const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH);
253281const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH);
282+/** @type {boolean} */
254283const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS);
255284
256285const uploadsPath = path.join(dataRoot, UPLOADS_DIRECTORY);
257286
258-const enableIPv6 = cliArguments.enableIPv6 ?? getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6);
259-const enableIPv4 = cliArguments.enableIPv4 ?? getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4);
260287
288+/** @type {boolean | "auto"} */
289+let enableIPv6 = stringToBool(cliArguments.enableIPv6) ?? getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6);
290+/** @type {boolean | "auto"} */
291+let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4);
292+
293+/** @type {string} */
261294const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME);
295+/** @type {number} */
262296const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT);
263297
298+/** @type {boolean} */
264299const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6);
265300
301+/** @type {boolean} */
266302const avoidLocalhost = cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', DEFAULT_AVOID_LOCALHOST);
267303
268304const proxyEnabled = cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', DEFAULT_PROXY_ENABLED);
@@ -279,7 +315,19 @@ if (dnsPreferIPv6) {
279315 console.log('Preferring IPv4 for DNS resolution');
280316}
281317
282-if (!enableIPv6 && !enableIPv4) {
318+
319+const ipOptions = [true, 'auto', false];
320+
321+if (!ipOptions.includes(enableIPv6)) {
322+ console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV6);
323+ enableIPv6 = DEFAULT_ENABLE_IPV6;
324+}
325+if (!ipOptions.includes(enableIPv4)) {
326+ console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV4);
327+ enableIPv4 = DEFAULT_ENABLE_IPV4;
328+}
329+
330+if (enableIPv6 === false && enableIPv4 === false) {
283331 console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
284332 process.exit(1);
285333}
@@ -292,9 +340,17 @@ const CORS = cors({
292340
293341app.use(CORS);
294342
295343if (listen && basicAuthMode) app.use(basicAuthMiddleware);{
344+ app.use(basicAuthMiddleware);
345+}
296346
297-app.use(whitelistMiddleware(enableWhitelist, listen));
347+if (enableWhitelist) {
348+ app.use(whitelistMiddleware());
349+}
350+
351+if (listen) {
352+ app.use(accessLoggerMiddleware());
353+}
298354
299355if (enableCorsProxy) {
300356 app.use(bodyParser.json({
@@ -364,6 +420,55 @@ function getSessionCookieAge() {
364420 return undefined;
365421}
366422
423+/**
424+ * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
425+ *
426+ * @returns {Promise<[boolean, boolean, boolean, boolean]>} A promise that resolves to an array containing:
427+ * - [0]: `hasIPv6` (boolean) - Whether the computer has any IPv6 address, including (`::1`).
428+ * - [1]: `hasIPv4` (boolean) - Whether the computer has any IPv4 address, including (`127.0.0.1`).
429+ * - [2]: `hasIPv6Local` (boolean) - Whether the computer has local IPv6 address (`::1`).
430+ * - [3]: `hasIPv4Local` (boolean) - Whether the computer has local IPv4 address (`127.0.0.1`).
431+ */
432+async function getHasIP() {
433+ let hasIPv6 = false;
434+ let hasIPv6Local = false;
435+
436+ let hasIPv4 = false;
437+ let hasIPv4Local = false;
438+
439+ const interfaces = os.networkInterfaces();
440+
441+ for (const iface of Object.values(interfaces)) {
442+ if (iface === undefined) {
443+ continue;
444+ }
445+
446+ for (const info of iface) {
447+ if (info.family === 'IPv6') {
448+ hasIPv6 = true;
449+ if (info.address === '::1') {
450+ hasIPv6Local = true;
451+ }
452+ }
453+
454+ if (info.family === 'IPv4') {
455+ hasIPv4 = true;
456+ if (info.address === '127.0.0.1') {
457+ hasIPv4Local = true;
458+ }
459+ }
460+ if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
461+ }
462+ if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
463+ }
464+ return [
465+ hasIPv6,
466+ hasIPv4,
467+ hasIPv6Local,
468+ hasIPv4Local,
469+ ];
470+}
471+
367472app.use(cookieSession({
368473 name: getCookieSessionName(),
369474 sameSite: 'strict',
@@ -419,7 +524,7 @@ if (!disableCsrf) {
419524
420525// Static files
421526// Host index page
422527app.get('/', getCacheBusterMiddleware(), (request, response) => {
423528 if (shouldRedirectToLogin(request)) {
424529 const query = request.url.split('?')[1];
425530 const redirectUrl = query ? `/login?${query}` : '/login';
@@ -459,7 +564,13 @@ app.use('/api/users', usersPublicRouter);
459564
460565// Everything below this line requires authentication
461566app.use(requireLoginMiddleware);
462567app.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+});
463574
464575// File uploads
465576app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
@@ -627,13 +738,13 @@ app.use('/api/azure', azureRouter);
627738
628739const tavernUrlV6 = new URL(
629740 (cliArguments.ssl ? 'https://' : 'http://') +
630741 (listen ? (ipRegex.v6({ exact: true }).test(listenAddressIPv6) ? listenAddressIPv6 : '[::]') : '[::1]') +
631742 (':' + server_port),
632743);
633744
634745const tavernUrl = new URL(
635746 (cliArguments.ssl ? 'https://' : 'http://') +
636747 (listen ? (ipRegex.v4({ exact: true }).test(listenAddressIPv4) ? listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') +
637748 (':' + server_port),
638749);
639750
@@ -657,6 +768,7 @@ const preSetupTasks = async function () {
657768 await checkForNewContent(directories);
658769 await ensureThumbnailCache();
659770 cleanUploads();
771+ migrateAccessLog();
660772
661773 await settingsInit();
662774 await statsInit();
@@ -693,20 +805,23 @@ const preSetupTasks = async function () {
693805
694806/**
695807 * Gets the hostname to use for autorun in the browser.
696808 * @returnsparam {stringboolean} The hostnameuseIPv6 toIf use for autorunIPv6
809+ * @param {boolean} useIPv4 If use IPv4
810+ * @returns Promise<string> The hostname to use for autorun
697811 */
698812async function getAutorunHostname(useIPv6, useIPv4) {
699813 if (autorunHostname === 'auto') {
700- if (enableIPv6 && enableIPv4) {
814+ let localhostResolve = await canResolve('localhost', useIPv6, useIPv4);
701- if (avoidLocalhost) return '[::1]';
815+
702- return 'localhost';
816+ if (useIPv6 && useIPv4) {
817+ return (avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost';
703818 }
704819
705820 if (enableIPv6useIPv6) {
src/constants.js+0 -0
src/endpoints/anthropic.js+0 -0
src/endpoints/assets.js+0 -0
src/endpoints/azure.js+0 -0
src/endpoints/backends/chat-completions.js+0 -0
src/endpoints/backends/kobold.js+0 -0
src/endpoints/backends/scale-alt.js+0 -0
src/endpoints/backends/text-completions.js+0 -0
src/endpoints/backgrounds.js+0 -0
src/endpoints/caption.js+0 -0
src/endpoints/characters.js+0 -0
src/endpoints/chats.js+0 -0
src/endpoints/classify.js+0 -0
src/endpoints/content-manager.js+0 -0
src/endpoints/extensions.js+0 -0
src/endpoints/files.js+0 -0
src/endpoints/google.js+0 -0
src/endpoints/groups.js+0 -0
src/endpoints/horde.js+0 -0
src/endpoints/images.js+0 -0
src/endpoints/novelai.js+0 -0
src/endpoints/openai.js+0 -0
src/endpoints/openrouter.js+0 -0
src/endpoints/presets.js+0 -0
src/endpoints/search.js+0 -0
src/endpoints/secrets.js+0 -0
src/endpoints/settings.js+0 -0
src/endpoints/speech.js+0 -0
src/endpoints/sprites.js+0 -0
src/endpoints/stable-diffusion.js+0 -0
src/endpoints/stats.js+0 -0
src/endpoints/thumbnails.js+0 -0
src/endpoints/tokenizers.js+0 -0
src/endpoints/translate.js+0 -0
src/endpoints/users-admin.js+0 -0
src/endpoints/users-private.js+0 -0
src/endpoints/users-public.js+0 -0
src/endpoints/vectors.js+0 -0
src/endpoints/worldinfo.js+0 -0
src/express-common.js+0 -0
src/middleware/accessLogWriter.js+0 -0
src/middleware/cacheBuster.js+0 -0
src/middleware/webpack-serve.js+0 -0
src/middleware/whitelist.js+0 -0
src/plugin-loader.js+0 -0
src/prompt-converters.js+0 -0
src/request-proxy.js+0 -0
src/users.js+0 -0
src/util.js+0 -0
src/vectors/cohere-vectors.js+0 -0
src/vectors/extras-vectors.js+0 -0
src/vectors/makersuite-vectors.js+0 -0
src/vectors/nomicai-vectors.js+0 -0
src/vectors/openai-vectors.js+0 -0
webpack.config.js+0 -0
Diff truncated